Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/12217-gallery-empty-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/plugin-list': patch
---

An empty gallery draws the same empty state as an empty grid, translated, instead of "No items to display" in English (objectui#12217).

`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.

- **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.
- **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.

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.
143 changes: 143 additions & 0 deletions packages/plugin-list/src/ListEmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* The empty state a list draws when it has no records (objectui#12217).
*
* ONE component, drawn by the two surfaces that show a list of records as
* rows or cards: `ListView` for its `grid` and `gallery` visualizations, and
* `ObjectGallery` when it is rendered without a `ListView` around it (an
* authored `object-gallery` node, `ObjectView`'s own non-grid path). It used to
* live inline in `ListView`'s grid branch, and `ObjectGallery` drew its own
* hard-coded English line instead, ignoring both the view's authored
* `emptyState` and the locale.
*
* Module-internal: not exported from the package barrel. The copy is the
* `list.*` keys the grid already read; ⛔ no second string table. Each caller
* hands over the translation function and display locale it already holds, the
* authored members it has (an authored icon NAME already resolved through the
* icon seam), and what it knows about the query that came back empty; a caller
* that has no search box or user filters (a standalone gallery) says `false`.
*/

import * as React from 'react';
import { Button, DataEmptyState } from '@object-ui/components';
import { Inbox, Plus, type LucideIcon } from 'lucide-react';
import type { ListViewSchema } from '@object-ui/types';
// The spec's own resolver for an `I18nLabel` (a plain string or an inline
// locale map), under the name `ListView` gives it.
import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui';

/**
* Whether a DECLARED filter (the view's own `filter`, or a standalone node's)
* narrows the query at all: a non-empty condition array or a non-empty
* filter-node object. An absent filter, `[]` and `{}` narrow nothing.
*/
export function hasDeclaredFilter(filter: unknown): boolean {
if (Array.isArray(filter)) return filter.length > 0;
if (filter && typeof filter === 'object') return Object.keys(filter).length > 0;
return false;
}

/** The authored `emptyState` (the spec's `EmptyStateSchema`). */
type AuthoredEmptyState = NonNullable<ListViewSchema['emptyState']>;

export interface ListEmptyStateProps {
/** The authored `emptyState.title` (`I18nLabel`), resolved here against `displayLocale`. */
title?: AuthoredEmptyState['title'];
/** The authored `emptyState.message` (`I18nLabel`), resolved here against `displayLocale`. */
message?: AuthoredEmptyState['message'];
/**
* The authored `emptyState.icon`, already resolved by the caller through the
* ONE icon seam (`resolveIcon`, objectui#5935); `null` or absent draws the
* `Inbox` glyph.
*/
icon?: LucideIcon | null;
/**
* The USER applied a query: the search box, the user-filter chips, the
* filter panel. Only then is the user told to adjust or clear their filters.
*/
hasUserQuery: boolean;
/** The declared filter narrows the query (see {@link hasDeclaredFilter}). */
hasBaseFilter: boolean;
/** Draws the add-record action when supplied; omitted, no action is drawn. */
onAddRecord?: () => void;
/** The caller's `list.*` translation function. */
t: (key: string) => string;
/** The locale an authored `I18nLabel` map is resolved against. */
displayLocale: string;
}

export function ListEmptyState({
title: authoredTitle,
message: authoredMessage,
icon,
hasUserQuery,
hasBaseFilter,
onAddRecord,
t,
displayLocale,
}: ListEmptyStateProps) {
// ⛔ The `Inbox` fallback stays here, at the surface — an empty state always
// shows a glyph, and that is this surface's decision, not the icon seam's
// (objectui#5935, maintainer ruling 2026-09-03, option C).
const ResolvedIcon: LucideIcon = icon ?? Inbox;
// Distinguish "filtered/searched to empty" from "truly empty (first run)".
// A new user with no filters shouldn't be told to "adjust your filters" —
// they should be invited to create.
//
// The declared filter counts as an active query too (objectui#4155). It used
// to be excluded, so a view that returns nothing *because it is filtered* —
// the declared `status not_in [archived]`, or a stale stored condition —
// rendered the first-run copy ("no data yet / create your first record")
// over an object full of records. That reads as data loss or a permission
// problem and sends triage away from the view layer.
//
// …but it is not the USER's query (objectui#11687). "Your current filters or
// search" is said only when the user applied one. A list emptied by its own
// declared filter alone gets its own message, which names the view's filter
// instead of telling the user to clear filters they never set.
const hasActiveQuery = hasUserQuery || hasBaseFilter;
// objectui#11227 — `title` and `message` are the spec's `I18nLabel`
// (`EmptyStateSchema`): a plain string or an inline locale map. They are
// RESOLVED against the display locale, as the view's `label` is. A
// `typeof === 'string'` test stood here, which is not a resolution: it
// answered "absent" for every map an author may write, so a localised empty
// state silently drew the default copy in every locale. A string still
// passes through unchanged, and a map with no usable entry still falls to
// the default.
const title = resolveInlineI18nLabel(authoredTitle, displayLocale)
?? (hasActiveQuery ? t('list.noMatches') : t('list.firstRunTitle'));
const description = resolveInlineI18nLabel(authoredMessage, displayLocale)
?? (hasUserQuery
? t('list.noMatchesMessage')
: hasBaseFilter
? t('list.viewFilterNoMatchesMessage')
: t('list.firstRunMessage'));
return (
<DataEmptyState
data-testid="empty-state"
className="h-full min-h-[200px] p-8 gap-1 [&>h3]:text-lg [&>h3]:font-medium [&>h3]:text-foreground [&>p]:max-w-md"
icon={<ResolvedIcon className="h-12 w-12 text-muted-foreground/50" />}
iconWrapperClassName="mb-3"
title={title}
description={description}
action={onAddRecord ? (
<Button
variant="default"
size="sm"
data-testid="empty-state-add-record"
onClick={() => onAddRecord()}
>
<Plus className="h-4 w-4 mr-1.5" />
{t('list.addRecord')}
</Button>
) : undefined}
/>
);
}
113 changes: 34 additions & 79 deletions packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
*/

import * as React from 'react';
import { cn, Button, Input, Popover, PopoverContent, PopoverTrigger, FilterBuilder, SortBuilder, NavigationOverlay, GroupingEditor, RefreshIndicator, DataEmptyState, DataErrorState, resolveIcon, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@object-ui/components';
import { cn, Button, Input, Popover, PopoverContent, PopoverTrigger, FilterBuilder, SortBuilder, NavigationOverlay, GroupingEditor, RefreshIndicator, DataErrorState, resolveIcon, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@object-ui/components';
import type { SortItem } from '@object-ui/components';
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';
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';
import type { FilterBuilderRowGroup } from '@object-ui/components';
import { VALUELESS_FILTER_BUILDER_OPERATORS, isFilterValueComplete } from '@object-ui/components';
import { ViewSwitcherDropdown } from './ViewSwitcher';
import { ListEmptyState, hasDeclaredFilter } from './ListEmptyState';
import { ViewSettingsPopover } from './components/ViewSettingsPopover';
import { UserFilters } from './UserFilters';
import {
Expand Down Expand Up @@ -1035,8 +1036,12 @@ export const LIST_DEFAULT_TRANSLATIONS: Record<string, string> = {
* Delegates to `@object-ui/i18n`'s `createSafeTranslation`. The local copy
* this replaced wrapped the hook in try/catch — the very thing the comment on
* `useListViewObjectLabel` below warns against (objectui#2879).
*
* Exported for this package's `ObjectGallery` only (objectui#12217), which
* draws `ListEmptyState`'s `list.*` copy when no `ListView` is around it; the
* package barrel does not re-export it.
*/
const useListViewTranslation = createSafeTranslation(
export const useListViewTranslation = createSafeTranslation(
LIST_DEFAULT_TRANSLATIONS,
'list.recordCount',
);
Expand Down Expand Up @@ -5674,84 +5679,34 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
/>
))}
</div>
) : !loading && data.length === 0 && currentView === 'grid' ? (
(() => {
const iconName = schema.emptyState?.icon;
// objectui#5935: normalisation through the ONE seam. This site had
// its own inline `split('-')` and no rename map, so `home` and every
// snake_case spelling fell through to `Inbox` here while resolving
// elsewhere. ⛔ The `Inbox` fallback itself stays at this call site
// — an empty state always shows a glyph, and that is this surface's
// decision, not the seam's (maintainer ruling 2026-09-03, option C).
const ResolvedIcon: LucideIcon = resolveIcon(iconName) ?? Inbox;
// Distinguish "filtered/searched to empty" from "truly empty
// (first run)". A new user with no filters shouldn't be told to
// "adjust your filters" — they should be invited to create.
//
// The VIEW's own `filter` counts as an active query too
// (objectui#4155). It used to be excluded, so a view that returns
// nothing *because it is filtered* — the declared `status not_in
// [archived]`, or a stale stored condition — rendered the first-run
// copy ("no data yet / create your first record") over an object
// full of records. That reads as data loss or a permission problem
// and sends triage away from the view layer, which is exactly what
// this issue reported.
//
// …but it is not the USER's query (objectui#11687). "Your current
// filters or search" is said only when the user applied one — the
// search box, the user-filter chips, the filter panel. A view
// emptied by its own declared filter alone gets its own message,
// which names the view's filter instead of telling the user to
// clear filters they never set.
const hasBaseFilter =
Array.isArray(authoredFilter)
? authoredFilter.length > 0
: !!authoredFilter && typeof authoredFilter === 'object'
? Object.keys(authoredFilter).length > 0
: false;
const hasUserQuery =
) : !loading && data.length === 0 && (currentView === 'grid' || currentView === 'gallery') ? (
// objectui#12217 — the gallery draws the grid's empty state, through
// the one shared component: the authored `emptyState`, resolved
// against the display locale, otherwise the localized `list.*` copy.
// It used to hand the empty list to `ObjectGallery`, which drew a
// hard-coded English line and never read the view's `emptyState`.
<ListEmptyState
title={schema.emptyState?.title}
message={schema.emptyState?.message}
// objectui#5935: the authored name is normalised through the ONE
// icon seam. This site once had its own inline `split('-')` and no
// rename map, so `home` and every snake_case spelling fell through
// to the fallback here while resolving elsewhere. What an
// unresolvable name draws is the empty state's own decision.
icon={resolveIcon(schema.emptyState?.icon)}
hasBaseFilter={hasDeclaredFilter(authoredFilter)}
// The USER's query (objectui#11687): the search box, the
// user-filter chips, the filter panel's own conditions. The view's
// declared `filter` is `hasBaseFilter`, not this.
hasUserQuery={
!!(searchTerm && searchTerm.trim()) ||
(Array.isArray(appliedUserFilterConditions) && appliedUserFilterConditions.length > 0) ||
(Array.isArray(appliedFilters?.conditions) && appliedFilters.conditions.length > 0);
const hasActiveQuery = hasUserQuery || hasBaseFilter;
// objectui#11227 — `title` and `message` are the spec's `I18nLabel`
// (`EmptyStateSchema`): a plain string or an inline locale map. They
// are RESOLVED against the display locale, as the view's `label`
// is. A `typeof === 'string'` test stood here, which is not a
// resolution: it answered "absent" for every map an author may write,
// so a localised empty state silently drew the default copy in every
// locale. A string still passes through unchanged, and a map with no
// usable entry still falls to the default.
const title = resolveInlineI18nLabel(schema.emptyState?.title, displayLocale)
?? (hasActiveQuery ? t('list.noMatches') : t('list.firstRunTitle'));
const description = resolveInlineI18nLabel(schema.emptyState?.message, displayLocale)
?? (hasUserQuery
? t('list.noMatchesMessage')
: hasBaseFilter
? t('list.viewFilterNoMatchesMessage')
: t('list.firstRunMessage'));
return (
<DataEmptyState
data-testid="empty-state"
className="h-full min-h-[200px] p-8 gap-1 [&>h3]:text-lg [&>h3]:font-medium [&>h3]:text-foreground [&>p]:max-w-md"
icon={<ResolvedIcon className="h-12 w-12 text-muted-foreground/50" />}
iconWrapperClassName="mb-3"
title={title}
description={description}
action={toolbarFlags.showAddRecord ? (
<Button
variant="default"
size="sm"
data-testid="empty-state-add-record"
onClick={() => props.onAddRecord?.()}
>
<Plus className="h-4 w-4 mr-1.5" />
{t('list.addRecord')}
</Button>
) : undefined}
/>
);
})()
(Array.isArray(appliedFilters?.conditions) && appliedFilters.conditions.length > 0)
}
onAddRecord={toolbarFlags.showAddRecord ? () => props.onAddRecord?.() : undefined}
t={t}
displayLocale={displayLocale}
/>
) : (
<SchemaRenderer
schema={viewComponentSchema}
Expand Down
29 changes: 26 additions & 3 deletions packages/plugin-list/src/ObjectGallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import { usePermissions } from '@object-ui/permissions';
import type { DataSource, GalleryConfig, ObjectGallerySchema, QueryParams } from '@object-ui/types';
import { ChevronRight, ChevronDown } from 'lucide-react';
import { getCellRenderer, resolveCellRendererType, readFileValues } from '@object-ui/fields';
import { useDisplayLocale } from '@object-ui/i18n';
import { ListEmptyState, hasDeclaredFilter } from './ListEmptyState';
import { useListViewTranslation } from './ListView';

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

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

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

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

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