Skip to content

Commit f5b66bf

Browse files
fix(plugin-list): an empty gallery draws the grid empty state, translated (objectui#12217) (#12235)
Fixes #12217 Clause-②: no An empty gallery now draws the same empty state an empty grid draws: the view's authored `emptyState` (`title` and `message` resolved against the display locale, its `icon`), otherwise the localized `list.*` copy. It used to draw a hard-coded English "No items to display" in every locale and ignored the authored `emptyState`. ## What changed - **One shared component, `ListEmptyState`** (new, module-internal: `packages/plugin-list/src/ListEmptyState.tsx`, not in the package barrel). The grid's inline empty-state block in `ListView` moved into it unchanged: the copy selection between the user's query, the view filter and first run (objectui#4155, objectui#11687), the `I18nLabel` resolution of `title` and `message` (objectui#11227), the `Inbox` glyph fallback, and the add-record action. No second string table, no new key. - **`ListView`** draws it for `gallery` as it does for `grid` (the empty branch's condition is now grid or gallery). The authored icon name is still resolved through `resolveIcon` inside `ListView`, and the component takes the resolved glyph. So `ListView.tsx` still calls the icon seam, which `scripts/__tests__/check-lucide-icon-record-names.test.ts` pins by path, and the change stays inside the claimed file surface. - **`ObjectGallery`**'s empty branch draws it standalone, through the translation hook `ListView` draws it with (`useListViewTranslation`, now a module-level export of `ListView.tsx`; the barrel does not re-export it) and `useDisplayLocale()`. ## H1: the two paths a gallery reaches, and the cloud page | Path | Before | After | |---|---|---| | Under `ListView` with `viewType: 'gallery'`: the console's object list (`app-shell` `ObjectView` hands `plugin-view`'s `ObjectView` a `renderListView` that mounts `ListView`) and an interface list page whose visualization is a gallery | `ListView`'s empty branch was grid-only, so the empty list went through `SchemaRenderer` to `ObjectGallery`, which drew the English line | `ListView` draws `ListEmptyState` itself; `ObjectGallery` is not mounted for an empty list | | Standalone: an authored `object-gallery` node, `plugin-view` `ObjectView`'s own non-grid path (no `renderListView`), `RelatedList`'s mobile card list | `ObjectGallery` drew the English line | `ObjectGallery` draws `ListEmptyState` | **The cloud page 「高级 → 我的软件包」 takes the `ListView` path.** It is a `type: 'list'` page (cloud's `sys-package.page.ts`, as objectstack-ai/objectstack#23005's evidence states), rendered by `@object-ui/app-shell`'s `InterfaceListPage`, which mounts `ListView` with `viewType` taken from `appearance.allowedVisualizations[0]`. This is read from source and was not measured on the cloud console: the cloud repository is not attached to this session. That page relays its own `filter` (`publisher = 'private'`) and has no page-level `emptyState` to relay (the spec gap objectstack-ai/objectstack#23005). So it will draw the view-filter wording the grid draws on the same page: in zh 「没有匹配的记录」 / 「没有符合该视图筛选条件的记录。」. `RelatedList` draws no card body while its list is empty (its `!isEmpty` guard), so its mobile gallery reaches the empty branch only in edge windows. ## H2: what each caller can see | Input | `ListView` | `ObjectGallery` standalone | |---|---|---| | authored `emptyState` | `schema.emptyState` (title, message, icon) | none: `ObjectGallerySchema` declares no `emptyState`, and adding it is a type change this card rules out | | view filter (`hasBaseFilter`) | the resolved view `filter` | its own `filter`, only when the gallery issued the query itself (`fetchesForItself`); rows a host handed in were filtered by the host | | user query (`hasUserQuery`) | search box, user-filter chips, filter panel | none (`false`) | | add-record action | `toolbarFlags.showAddRecord` | none: the gallery has no such control | | translation and locale | `useListViewTranslation`, `useDisplayLocale` | the same two hooks | Chosen shape: `ListView` draws the shared component for `gallery` as it does for `grid`. That is the simplest correct shape, and `ListView` holds every input. `ObjectGallery` draws the component standalone with what it knows. The filter predicate is the shared `hasDeclaredFilter`, so the two callers cannot disagree about what a non-empty filter is. ## H3: i18n The copy uses the six `list.*` keys the grid already read: `list.noMatches`, `list.firstRunTitle`, `list.noMatchesMessage`, `list.viewFilterNoMatchesMessage`, `list.firstRunMessage` and `list.addRecord`. All six sit under `list` in the built-in `zh` pack, and the provider-less fallback is `LIST_DEFAULT_TRANSLATIONS`. `ObjectGallery` reaches them through `ListView`'s own `useListViewTranslation`. No key is added; `pnpm check:i18n-keys` is green. ## H4: other view types' empty branches (measured, not changed) These are cited by file and quoted text, because AGENTS.md rule #11 bans line addresses in a pull-request body. - **kanban**, `packages/plugin-kanban/src/KanbanImpl.tsx`: the board-level `DataEmptyState` reads `t('kanban.noCards')`. It is localized, but it does not read the view's authored `emptyState`. - **calendar**, `packages/plugin-calendar/src/ObjectCalendar.tsx`: there is no empty-state copy; an empty calendar draws its grid. - **timeline**, `packages/plugin-timeline/src/ObjectTimeline.tsx`: there is no empty-state copy. The loading placeholder carries a hard-coded English screen-reader line, `Loading timeline…`. - **gantt**, `packages/plugin-gantt/src/ObjectGantt.tsx` and `GanttView.tsx`: there is no empty-state copy; an empty chart draws its axis. - **map**, `packages/plugin-map/src/ObjectMap.tsx`: there is no empty-state copy over a working map. The WebGL-failure fallback list draws hard-coded English `No locations to list.`, beside the hard-coded failure sentence that starts `Map failed to load`. - **tree**, `packages/plugin-tree/src/ObjectTree.tsx`: an empty tree draws hard-coded English `No records` (and `Loading…` while loading), although `useTreeTranslation` is in scope in that component. - **chart**, `packages/plugin-charts/src/ObjectChart.tsx`: a localized `DataEmptyState` through `dashboard.empty.title` (inline default `No data yet`). ## Tests and gates (all at `d7f71fd`, this branch's head) - **New pins**, `packages/plugin-list/src/__tests__/ObjectGallery.emptyState-12217.test.tsx`, 10 rows: - Under `ListView`: zh first-run copy and the zh add-record label; an authored inline locale map painting its zh-CN entries; an authored plain string drawn as written; and the gallery and the grid drawing the same empty state node for node. - Standalone: zh first-run copy; built-in English copy with no add-record action; the view-filter wording when its own `filter` emptied its own query; and a no-filter control. - Two positive controls prove the gallery really is the visualization mounted. - **Reverse verification.** `ListView.tsx` and `ObjectGallery.tsx` were checked out at the base `a491b64` (hash-equal to the base blobs; on disk `No items to display` = 1 and `ListEmptyState` = 0 in `ListView.tsx`). The new file then ran **8 failed, 2 passed**: the eight rows that need the fix went red and the two controls stayed green, the direction predicted. Both paths were restored from `HEAD` under a trap and are hash-equal to the `HEAD` blobs, with `git diff HEAD` empty. - **Controls the card names**, unchanged and green: `ListView.emptyVsErrorRole-7132`, `ListView.emptyStateFilteredCopy` (objectui#4155, objectui#11687 rows), `ListView.emptyStateI18nLabel-11227` and `icon-seam-fallbacks-5935`. - **Targeted vitest**, run from the repository root: - 40 `plugin-list` files (every `ObjectGallery` test, every `ListView` empty-state test, the gallery-touching `ListView` tests): `Test Files 40 passed (40)`, `Tests 457 passed (457)`. - 38 files outside the package that read `ListView.tsx` / `ObjectGallery.tsx` source or render a gallery through them. These include the `InterfaceListPage` relay census, the lucide icon census, the `ObjectView` gallery tests, `RelatedList`'s mobile gallery and the `defaults-maps-mirror-en-pack` gate: `Test Files 38 passed (38)`, `Tests 845 passed (845)`. - The existing `ObjectGallery.fetchGate-7903` negative assertion asserted on the old English string, so it would have passed for nothing. It now looks for the shared component's test id instead. - **Builds and type-check.** The dependency closure was built with `turbo run build --filter='@object-ui/plugin-list^...'` (12/12). `pnpm --filter @object-ui/plugin-list run type-check` echoed `type-check` and ran both `tsc` projects, exit 0. `tsconfig.test.json`'s file list includes the new test. - **Published face.** `dist/index.d.ts` names none of `ListEmptyState`, `useListViewTranslation` or `hasDeclaredFilter`, and neither `src/index.tsx` nor `package.json` changed. The per-file declarations gain `ListEmptyState.d.ts`, and `ListView.d.ts` now exports `useListViewTranslation`. The `exports` map exposes only the package root, so neither is reachable through it. - **Gates, exit 0:** `check:vi-mock-specifiers`, `check:test-path-roots`, `check:unreferenced-sources`, `check-changeset-presence` (5 source files of 1 released package, 1 changeset), `check:changeset-claims`, `check:pending-changeset-literals`, `check:control-bytes`, `check:new-line-citations` (0 new citations). The derived gates are green too: `check:icon-record-names`, `check:i18n-keys`, `check:i18n-dead-keys`, `check:i18n-drift`, `check:phantom-deps`, `check:unused-deps`, `check:vi-mock-inherit`, `check:vi-mock-override-shape` and `check:handler-key-reads`. - **Lint, narrowed and declared.** eslint ran with the repository config on the 5 changed `.ts`/`.tsx` files (count read from `--format json`): 0 errors. The config extends `tseslint.configs.recommended` with no `parserOptions.project` / `projectService`, so linting is not type-aware and this diff cannot move a verdict on an untouched file. The full `eslint .` run is CI's. - **NOT MEASURED** (each needs a console or all-package build this container did not make; CI runs them): - `check:readme-exports`: 386 self-imports unjudged because packages are unbuilt. - `check:eager-closure`: no `apps/console/dist/eager-closure.json`. The console aliases `@object-ui/plugin-list` to its `src`, whose barrel already imports both `ListView` and `ObjectGallery`, so the new `ObjectGallery` to `ListView` edge adds no module to any closure; `ListEmptyState.tsx` is moved code. - `check:sdui-registration-pins`: no console build. The `object-gallery` registration is unchanged. ## Acceptance notes - **Standalone galleries have no authored empty copy to show.** `ObjectGallerySchema` declares no `emptyState`, and `plugin-view` `ObjectView`'s own path (no `renderListView`) relays a named view's `emptyState` to no visualization, its grid included. Only its `renderListView` branch relays it. Not changed here: either half is a type or relay change outside this card. - **The tree's `No records` and the map's `No locations to list.` are the same untranslated-empty-copy family as this card.** They are listed in H4. Neither is filed here, and neither was measured at a public door. - **Docs.** No page under `content/docs` or `packages/plugin-list/README.md` describes the list's empty-state copy, so nothing went stale. --- _Generated by [Claude Code](https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5c2982a commit f5b66bf

6 files changed

Lines changed: 480 additions & 85 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/plugin-list': patch
3+
---
4+
5+
An empty gallery draws the same empty state as an empty grid, translated, instead of "No items to display" in English (objectui#12217).
6+
7+
`ObjectGallery` used to draw one hard-coded English line, "No items to display", whenever it had no records, in every locale. It also ignored the view's authored `emptyState`, because `ListView` drew its empty state only for the `grid` visualization and handed an empty gallery the empty list.
8+
9+
- **Under `ListView`** (an object list or an interface list page shown as a gallery), the gallery now draws the grid's empty state. That is the view's authored `emptyState` (`title` and `message` resolved against the display locale, its `icon`) when there is one, and otherwise the localized default copy: the first-run message, or the no-matches message when the user's search or filters or the view's own filter emptied the list. The add-record action appears as it does on the grid.
10+
- **Standalone** (an `object-gallery` node, or `ObjectView`'s own gallery path), the gallery draws the same component with what it knows. That is the localized first-run copy, or the view-filter message when its own `filter` emptied its own query. The `object-gallery` node declares no `emptyState`, and the standalone gallery has no add-record control.
11+
12+
The copy uses the `list.*` keys the grid already used, so every language pack already carries it. Nothing is added to the package entry: no export, prop, type member or language-pack key.
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* The empty state a list draws when it has no records (objectui#12217).
11+
*
12+
* ONE component, drawn by the two surfaces that show a list of records as
13+
* rows or cards: `ListView` for its `grid` and `gallery` visualizations, and
14+
* `ObjectGallery` when it is rendered without a `ListView` around it (an
15+
* authored `object-gallery` node, `ObjectView`'s own non-grid path). It used to
16+
* live inline in `ListView`'s grid branch, and `ObjectGallery` drew its own
17+
* hard-coded English line instead, ignoring both the view's authored
18+
* `emptyState` and the locale.
19+
*
20+
* Module-internal: not exported from the package barrel. The copy is the
21+
* `list.*` keys the grid already read; ⛔ no second string table. Each caller
22+
* hands over the translation function and display locale it already holds, the
23+
* authored members it has (an authored icon NAME already resolved through the
24+
* icon seam), and what it knows about the query that came back empty; a caller
25+
* that has no search box or user filters (a standalone gallery) says `false`.
26+
*/
27+
28+
import * as React from 'react';
29+
import { Button, DataEmptyState } from '@object-ui/components';
30+
import { Inbox, Plus, type LucideIcon } from 'lucide-react';
31+
import type { ListViewSchema } from '@object-ui/types';
32+
// The spec's own resolver for an `I18nLabel` (a plain string or an inline
33+
// locale map), under the name `ListView` gives it.
34+
import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui';
35+
36+
/**
37+
* Whether a DECLARED filter (the view's own `filter`, or a standalone node's)
38+
* narrows the query at all: a non-empty condition array or a non-empty
39+
* filter-node object. An absent filter, `[]` and `{}` narrow nothing.
40+
*/
41+
export function hasDeclaredFilter(filter: unknown): boolean {
42+
if (Array.isArray(filter)) return filter.length > 0;
43+
if (filter && typeof filter === 'object') return Object.keys(filter).length > 0;
44+
return false;
45+
}
46+
47+
/** The authored `emptyState` (the spec's `EmptyStateSchema`). */
48+
type AuthoredEmptyState = NonNullable<ListViewSchema['emptyState']>;
49+
50+
export interface ListEmptyStateProps {
51+
/** The authored `emptyState.title` (`I18nLabel`), resolved here against `displayLocale`. */
52+
title?: AuthoredEmptyState['title'];
53+
/** The authored `emptyState.message` (`I18nLabel`), resolved here against `displayLocale`. */
54+
message?: AuthoredEmptyState['message'];
55+
/**
56+
* The authored `emptyState.icon`, already resolved by the caller through the
57+
* ONE icon seam (`resolveIcon`, objectui#5935); `null` or absent draws the
58+
* `Inbox` glyph.
59+
*/
60+
icon?: LucideIcon | null;
61+
/**
62+
* The USER applied a query: the search box, the user-filter chips, the
63+
* filter panel. Only then is the user told to adjust or clear their filters.
64+
*/
65+
hasUserQuery: boolean;
66+
/** The declared filter narrows the query (see {@link hasDeclaredFilter}). */
67+
hasBaseFilter: boolean;
68+
/** Draws the add-record action when supplied; omitted, no action is drawn. */
69+
onAddRecord?: () => void;
70+
/** The caller's `list.*` translation function. */
71+
t: (key: string) => string;
72+
/** The locale an authored `I18nLabel` map is resolved against. */
73+
displayLocale: string;
74+
}
75+
76+
export function ListEmptyState({
77+
title: authoredTitle,
78+
message: authoredMessage,
79+
icon,
80+
hasUserQuery,
81+
hasBaseFilter,
82+
onAddRecord,
83+
t,
84+
displayLocale,
85+
}: ListEmptyStateProps) {
86+
// ⛔ The `Inbox` fallback stays here, at the surface — an empty state always
87+
// shows a glyph, and that is this surface's decision, not the icon seam's
88+
// (objectui#5935, maintainer ruling 2026-09-03, option C).
89+
const ResolvedIcon: LucideIcon = icon ?? Inbox;
90+
// Distinguish "filtered/searched to empty" from "truly empty (first run)".
91+
// A new user with no filters shouldn't be told to "adjust your filters" —
92+
// they should be invited to create.
93+
//
94+
// The declared filter counts as an active query too (objectui#4155). It used
95+
// to be excluded, so a view that returns nothing *because it is filtered* —
96+
// the declared `status not_in [archived]`, or a stale stored condition —
97+
// rendered the first-run copy ("no data yet / create your first record")
98+
// over an object full of records. That reads as data loss or a permission
99+
// problem and sends triage away from the view layer.
100+
//
101+
// …but it is not the USER's query (objectui#11687). "Your current filters or
102+
// search" is said only when the user applied one. A list emptied by its own
103+
// declared filter alone gets its own message, which names the view's filter
104+
// instead of telling the user to clear filters they never set.
105+
const hasActiveQuery = hasUserQuery || hasBaseFilter;
106+
// objectui#11227 — `title` and `message` are the spec's `I18nLabel`
107+
// (`EmptyStateSchema`): a plain string or an inline locale map. They are
108+
// RESOLVED against the display locale, as the view's `label` is. A
109+
// `typeof === 'string'` test stood here, which is not a resolution: it
110+
// answered "absent" for every map an author may write, so a localised empty
111+
// state silently drew the default copy in every locale. A string still
112+
// passes through unchanged, and a map with no usable entry still falls to
113+
// the default.
114+
const title = resolveInlineI18nLabel(authoredTitle, displayLocale)
115+
?? (hasActiveQuery ? t('list.noMatches') : t('list.firstRunTitle'));
116+
const description = resolveInlineI18nLabel(authoredMessage, displayLocale)
117+
?? (hasUserQuery
118+
? t('list.noMatchesMessage')
119+
: hasBaseFilter
120+
? t('list.viewFilterNoMatchesMessage')
121+
: t('list.firstRunMessage'));
122+
return (
123+
<DataEmptyState
124+
data-testid="empty-state"
125+
className="h-full min-h-[200px] p-8 gap-1 [&>h3]:text-lg [&>h3]:font-medium [&>h3]:text-foreground [&>p]:max-w-md"
126+
icon={<ResolvedIcon className="h-12 w-12 text-muted-foreground/50" />}
127+
iconWrapperClassName="mb-3"
128+
title={title}
129+
description={description}
130+
action={onAddRecord ? (
131+
<Button
132+
variant="default"
133+
size="sm"
134+
data-testid="empty-state-add-record"
135+
onClick={() => onAddRecord()}
136+
>
137+
<Plus className="h-4 w-4 mr-1.5" />
138+
{t('list.addRecord')}
139+
</Button>
140+
) : undefined}
141+
/>
142+
);
143+
}

‎packages/plugin-list/src/ListView.tsx‎

Lines changed: 34 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@
77
*/
88

99
import * as React from 'react';
10-
import { cn, Button, Input, Popover, PopoverContent, PopoverTrigger, FilterBuilder, SortBuilder, NavigationOverlay, GroupingEditor, RefreshIndicator, DataEmptyState, DataErrorState, resolveIcon, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@object-ui/components';
10+
import { cn, Button, Input, Popover, PopoverContent, PopoverTrigger, FilterBuilder, SortBuilder, NavigationOverlay, GroupingEditor, RefreshIndicator, DataErrorState, resolveIcon, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@object-ui/components';
1111
import type { SortItem } from '@object-ui/components';
12-
import { Search, SlidersHorizontal, ArrowUpDown, X, EyeOff, Pencil, Group, Paintbrush, Inbox, Download, Rows4, Rows3, Rows2, Share2, Printer, Plus, Trash2, CheckSquare, AlertTriangle, ShieldAlert, RotateCw, Loader2, type LucideIcon } from 'lucide-react';
12+
import { Search, SlidersHorizontal, ArrowUpDown, X, EyeOff, Pencil, Group, Paintbrush, Download, Rows4, Rows3, Rows2, Share2, Printer, Plus, Trash2, CheckSquare, AlertTriangle, ShieldAlert, RotateCw, Loader2 } from 'lucide-react';
1313
import type { FilterBuilderRowGroup } from '@object-ui/components';
1414
import { VALUELESS_FILTER_BUILDER_OPERATORS, isFilterValueComplete } from '@object-ui/components';
1515
import { ViewSwitcherDropdown } from './ViewSwitcher';
16+
import { ListEmptyState, hasDeclaredFilter } from './ListEmptyState';
1617
import { ViewSettingsPopover } from './components/ViewSettingsPopover';
1718
import { UserFilters } from './UserFilters';
1819
import {
@@ -1035,8 +1036,12 @@ export const LIST_DEFAULT_TRANSLATIONS: Record<string, string> = {
10351036
* Delegates to `@object-ui/i18n`'s `createSafeTranslation`. The local copy
10361037
* this replaced wrapped the hook in try/catch — the very thing the comment on
10371038
* `useListViewObjectLabel` below warns against (objectui#2879).
1039+
*
1040+
* Exported for this package's `ObjectGallery` only (objectui#12217), which
1041+
* draws `ListEmptyState`'s `list.*` copy when no `ListView` is around it; the
1042+
* package barrel does not re-export it.
10381043
*/
1039-
const useListViewTranslation = createSafeTranslation(
1044+
export const useListViewTranslation = createSafeTranslation(
10401045
LIST_DEFAULT_TRANSLATIONS,
10411046
'list.recordCount',
10421047
);
@@ -5674,84 +5679,34 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
56745679
/>
56755680
))}
56765681
</div>
5677-
) : !loading && data.length === 0 && currentView === 'grid' ? (
5678-
(() => {
5679-
const iconName = schema.emptyState?.icon;
5680-
// objectui#5935: normalisation through the ONE seam. This site had
5681-
// its own inline `split('-')` and no rename map, so `home` and every
5682-
// snake_case spelling fell through to `Inbox` here while resolving
5683-
// elsewhere. ⛔ The `Inbox` fallback itself stays at this call site
5684-
// — an empty state always shows a glyph, and that is this surface's
5685-
// decision, not the seam's (maintainer ruling 2026-09-03, option C).
5686-
const ResolvedIcon: LucideIcon = resolveIcon(iconName) ?? Inbox;
5687-
// Distinguish "filtered/searched to empty" from "truly empty
5688-
// (first run)". A new user with no filters shouldn't be told to
5689-
// "adjust your filters" — they should be invited to create.
5690-
//
5691-
// The VIEW's own `filter` counts as an active query too
5692-
// (objectui#4155). It used to be excluded, so a view that returns
5693-
// nothing *because it is filtered* — the declared `status not_in
5694-
// [archived]`, or a stale stored condition — rendered the first-run
5695-
// copy ("no data yet / create your first record") over an object
5696-
// full of records. That reads as data loss or a permission problem
5697-
// and sends triage away from the view layer, which is exactly what
5698-
// this issue reported.
5699-
//
5700-
// …but it is not the USER's query (objectui#11687). "Your current
5701-
// filters or search" is said only when the user applied one — the
5702-
// search box, the user-filter chips, the filter panel. A view
5703-
// emptied by its own declared filter alone gets its own message,
5704-
// which names the view's filter instead of telling the user to
5705-
// clear filters they never set.
5706-
const hasBaseFilter =
5707-
Array.isArray(authoredFilter)
5708-
? authoredFilter.length > 0
5709-
: !!authoredFilter && typeof authoredFilter === 'object'
5710-
? Object.keys(authoredFilter).length > 0
5711-
: false;
5712-
const hasUserQuery =
5682+
) : !loading && data.length === 0 && (currentView === 'grid' || currentView === 'gallery') ? (
5683+
// objectui#12217 — the gallery draws the grid's empty state, through
5684+
// the one shared component: the authored `emptyState`, resolved
5685+
// against the display locale, otherwise the localized `list.*` copy.
5686+
// It used to hand the empty list to `ObjectGallery`, which drew a
5687+
// hard-coded English line and never read the view's `emptyState`.
5688+
<ListEmptyState
5689+
title={schema.emptyState?.title}
5690+
message={schema.emptyState?.message}
5691+
// objectui#5935: the authored name is normalised through the ONE
5692+
// icon seam. This site once had its own inline `split('-')` and no
5693+
// rename map, so `home` and every snake_case spelling fell through
5694+
// to the fallback here while resolving elsewhere. What an
5695+
// unresolvable name draws is the empty state's own decision.
5696+
icon={resolveIcon(schema.emptyState?.icon)}
5697+
hasBaseFilter={hasDeclaredFilter(authoredFilter)}
5698+
// The USER's query (objectui#11687): the search box, the
5699+
// user-filter chips, the filter panel's own conditions. The view's
5700+
// declared `filter` is `hasBaseFilter`, not this.
5701+
hasUserQuery={
57135702
!!(searchTerm && searchTerm.trim()) ||
57145703
(Array.isArray(appliedUserFilterConditions) && appliedUserFilterConditions.length > 0) ||
5715-
(Array.isArray(appliedFilters?.conditions) && appliedFilters.conditions.length > 0);
5716-
const hasActiveQuery = hasUserQuery || hasBaseFilter;
5717-
// objectui#11227 — `title` and `message` are the spec's `I18nLabel`
5718-
// (`EmptyStateSchema`): a plain string or an inline locale map. They
5719-
// are RESOLVED against the display locale, as the view's `label`
5720-
// is. A `typeof === 'string'` test stood here, which is not a
5721-
// resolution: it answered "absent" for every map an author may write,
5722-
// so a localised empty state silently drew the default copy in every
5723-
// locale. A string still passes through unchanged, and a map with no
5724-
// usable entry still falls to the default.
5725-
const title = resolveInlineI18nLabel(schema.emptyState?.title, displayLocale)
5726-
?? (hasActiveQuery ? t('list.noMatches') : t('list.firstRunTitle'));
5727-
const description = resolveInlineI18nLabel(schema.emptyState?.message, displayLocale)
5728-
?? (hasUserQuery
5729-
? t('list.noMatchesMessage')
5730-
: hasBaseFilter
5731-
? t('list.viewFilterNoMatchesMessage')
5732-
: t('list.firstRunMessage'));
5733-
return (
5734-
<DataEmptyState
5735-
data-testid="empty-state"
5736-
className="h-full min-h-[200px] p-8 gap-1 [&>h3]:text-lg [&>h3]:font-medium [&>h3]:text-foreground [&>p]:max-w-md"
5737-
icon={<ResolvedIcon className="h-12 w-12 text-muted-foreground/50" />}
5738-
iconWrapperClassName="mb-3"
5739-
title={title}
5740-
description={description}
5741-
action={toolbarFlags.showAddRecord ? (
5742-
<Button
5743-
variant="default"
5744-
size="sm"
5745-
data-testid="empty-state-add-record"
5746-
onClick={() => props.onAddRecord?.()}
5747-
>
5748-
<Plus className="h-4 w-4 mr-1.5" />
5749-
{t('list.addRecord')}
5750-
</Button>
5751-
) : undefined}
5752-
/>
5753-
);
5754-
})()
5704+
(Array.isArray(appliedFilters?.conditions) && appliedFilters.conditions.length > 0)
5705+
}
5706+
onAddRecord={toolbarFlags.showAddRecord ? () => props.onAddRecord?.() : undefined}
5707+
t={t}
5708+
displayLocale={displayLocale}
5709+
/>
57555710
) : (
57565711
<SchemaRenderer
57575712
schema={viewComponentSchema}

‎packages/plugin-list/src/ObjectGallery.tsx‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import { usePermissions } from '@object-ui/permissions';
1414
import type { DataSource, GalleryConfig, ObjectGallerySchema, QueryParams } from '@object-ui/types';
1515
import { ChevronRight, ChevronDown } from 'lucide-react';
1616
import { getCellRenderer, resolveCellRendererType, readFileValues } from '@object-ui/fields';
17+
import { useDisplayLocale } from '@object-ui/i18n';
18+
import { ListEmptyState, hasDeclaredFilter } from './ListEmptyState';
19+
import { useListViewTranslation } from './ListView';
1720

1821
export interface ObjectGalleryProps {
1922
/**
@@ -350,6 +353,10 @@ export const ObjectGallery: React.FC<ObjectGalleryProps> = (props) => {
350353

351354
// i18n: translate select-field option labels in card cells
352355
const { fieldLabel, fieldOptionLabel } = useSafeFieldLabel();
356+
// The empty state's `list.*` copy and the locale it resolves against
357+
// (objectui#12217): the same translation hook `ListView` draws it with.
358+
const { t } = useListViewTranslation();
359+
const displayLocale = useDisplayLocale();
353360

354361
// Build an enriched FieldMetadata for a given field name so the shared
355362
// cell renderer pipeline (used by Detail/Grid/Related) receives the
@@ -560,8 +567,8 @@ export const ObjectGallery: React.FC<ObjectGalleryProps> = (props) => {
560567
if (!objectDefReady) {
561568
// ⚠️ Hold the placeholder across the gate window. `loading` starts
562569
// `false` here and was only ever flipped inside `fetchData`, so a
563-
// bare `return` would leave the gallery showing "No items to
564-
// display" — a FALSE empty state — for the whole metadata read,
570+
// bare `return` would leave the gallery showing its empty state
571+
// — a FALSE one — for the whole metadata read,
565572
// where before the gate it showed the loading placeholder. The
566573
// two siblings get this for free from their own initial state
567574
// (`ObjectCalendar` starts `loading` at `true`; `ObjectTimeline`
@@ -650,7 +657,23 @@ export const ObjectGallery: React.FC<ObjectGalleryProps> = (props) => {
650657
}, [items, groupingFields, isGrouped, collapsedGroups, defaultCollapsed]);
651658

652659
if (loading && !items.length) return <div className="p-4 text-sm text-muted-foreground">Loading Gallery...</div>;
653-
if (!items.length) return <div className="p-4 text-sm text-muted-foreground">No items to display</div>;
660+
// objectui#12217 — the empty state `ListView` draws for a grid, through the
661+
// one shared component. Under `ListView` this branch is not reached: the
662+
// view draws it itself, with the view's authored `emptyState` and the
663+
// toolbar's search and filters. Standalone, the gallery says what it
664+
// knows: the `object-gallery` node declares no `emptyState`, there is no
665+
// search box, user filter or add-record control here, and its own
666+
// `filter` emptied the list only when the gallery issued the query.
667+
if (!items.length) {
668+
return (
669+
<ListEmptyState
670+
hasUserQuery={false}
671+
hasBaseFilter={fetchesForItself && hasDeclaredFilter(authoredFilter)}
672+
t={t}
673+
displayLocale={displayLocale}
674+
/>
675+
);
676+
}
654677

655678
// Detail rows (description / tags / badges) shared by both card layouts.
656679
const renderFields = (item: Record<string, unknown>) => {

0 commit comments

Comments
 (0)