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
19 changes: 19 additions & 0 deletions .changeset/12270-empty-state-link.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@object-ui/components': minor
'@object-ui/plugin-list': minor
'@object-ui/plugin-grid': minor
'@object-ui/app-shell': minor
---

An empty list draws its declared link, and a list page's own empty state wins over its source view's (objectui#12270).

`@objectstack/spec` 18 gives the empty state (`EmptyStateSchema`) one optional `link: { label, url }`, and a list page's `interfaceConfig` an `emptyState` of its own with that shape. Both parsed and neither was drawn:

- a list view's empty state (`ListView`, on its `grid` and `gallery` visualizations) and the `object-grid` block's authored empty state now draw the link, beside the add-record action where the view offers one;
- a list page's own `interfaceConfig.emptyState` now wins, whole, over the one its source view declares, on every visualization; the source view's is the fallback when the page declares none.

The link is followed as a `type: 'url'` action through the action runner, so it opens what such an action opens: an in-app path in place, an `http(s)://` address as the runner opens an external target, and `mailto:` in place. The runner refuses anything else at the click and reports it. The link's `label` takes a string or an inline locale map.

`@object-ui/components` exports `EmptyStateLink`, the one component every empty state draws the link with.

**Clause-②: yes (widening)** — the renderers start honouring two keys the spec declares (`emptyState.link`, `interfaceConfig.emptyState`), so what they draw widens. No existing key changes.
2 changes: 1 addition & 1 deletion content/docs/api/schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -827,7 +827,7 @@ the spec's row did not declare until objectstack#20694; it is authorable in the
| `frozenColumns` | `number` | Number of columns frozen on scroll. |
| `conditionalFormatting` | `ConditionalFormattingRule[]` | Row styling rules, each `{ condition, style }` — a CEL `condition` over the row's `record.*` and a CSS `style` map, the rule a list view declares; the first matching rule styles the row. The native `{ field, operator, value }` rule, its `expression`, and a colour written beside `condition` (rather than inside `style`) are retired (objectui#11533): `@object-ui/types` refuses them by name on `ObjectGridSchema`, the `object-view` `table` slot and `list-view`. `{ field: 'priority', operator: 'equals', value: 'high', backgroundColor: '#fee2e2' }` is `{ condition: "record.priority == 'high'", style: { backgroundColor: '#fee2e2' } }`. In an authored node's `properties` bag the member is `@objectstack/spec`'s row member, judged by the installed spec. A grid stored with a retired rule still paints it. |
| `navigation` | `ViewNavigationConfig` | SPA navigation configuration. |
| `emptyState` | `EmptyState` — `{ title?, message?, icon? }` | Drawn in place of an empty table: a Lucide `icon`, a `title` (default: the table's "No results found") and a `message` (default: none). `title` and `message` are `string \| I18nLabel`, each resolved against the display locale; a map with no usable entry keeps that member's default. Not drawn when a term in the grid's own server-side search box emptied it — the table and its search box stay (objectui#11068). Authored in the `properties` bag: the spec's row declares it as the list view's own `EmptyStateSchema` (objectstack#20694, objectui#11227). |
| `emptyState` | `EmptyState` — `{ title?, message?, icon?, link? }` | Drawn in place of an empty table: a Lucide `icon`, a `title` (default: the table's "No results found"), a `message` (default: none) and one `link: { label, url }` the user follows (default: none), opened as a `type: 'url'` action opens it, through the action runner (objectui#12270). `title`, `message` and the link's `label` are `string \| I18nLabel`, each resolved against the display locale; a map with no usable entry keeps that member's default. Not drawn when a term in the grid's own server-side search box emptied it — the table and its search box stay (objectui#11068). Authored in the `properties` bag: the spec's row declares it as the list view's own `EmptyStateSchema` (objectstack#20694, objectui#11227). |

> **`name`, `placeholder`, `rowSpecActions` and `bulkSpecActions` are retired on
> this node (objectui#11068).** Nothing ever read them: `rowSpecActions` /
Expand Down
29 changes: 18 additions & 11 deletions content/docs/plugins/plugin-grid.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ below for why this plugin deliberately does not claim it.
| `columns` | `string[] \| ListColumn[]` | Field names or column objects — see below. |
| `label` | `I18nLabel` | Table caption and export file title. |
| `description` | `I18nLabel` | One line of help text above the grid — see `description` and `emptyState` below. |
| `emptyState` | `EmptyState` (`@objectstack/spec/ui`) | `{ title?, message?, icon? }`, drawn in place of an empty table — see below. |
| `emptyState` | `EmptyState` (`@objectstack/spec/ui`) | `{ title?, message?, icon?, link? }`, drawn in place of an empty table — see below. |
| `filter` | `ViewFilterRule[]` | Baked into the query, lowered to `$filter`. |
| `sort` | `[{ field, order }]` | Initial order; a header click replaces it. |
| `pagination` | `PaginationConfig` | `{ pageSize?, pageSizeOptions? }` — **strict**, and its presence is what enables paging. |
Expand Down Expand Up @@ -123,15 +123,21 @@ row declares both (objectstack#20694), so both are bag members and both are in

- `description` — one line of help text drawn above the grid. A string, or an
inline locale map resolved against the display locale the way `label` is.
- `emptyState: { title?, message?, icon? }` — the list view's own empty-state
shape (`EmptyState`), drawn **in place of** an empty table: a Lucide `icon`, a
`title` (default: the table's own "No results found") and a `message`
(default: none). `title` and `message` each take a string or an inline locale
map, resolved against the display locale; a map with no usable entry keeps
that member's default. It is not drawn when a term typed into the grid's own
server-side search box is what emptied it — the table and its search box stay,
so the term can be cleared. Leave the key out and an empty grid draws the
table's own empty row, as before.
- `emptyState: { title?, message?, icon?, link? }` — the list view's own
empty-state shape (`EmptyState`), drawn **in place of** an empty table: a
Lucide `icon`, a `title` (default: the table's own "No results found"), a
`message` (default: none) and one `link: { label, url }` the user follows
(default: none; objectui#12270). `title`, `message` and the link's `label`
each take a string or an inline locale map, resolved against the display
locale; a map with no usable entry keeps that member's default, and a link
whose label resolves to no text is not drawn. The link is followed as a
`type: 'url'` action through the action runner, so it opens what such an
action opens: an in-app path (`/…` or `./…`) in place, an `http(s)://`
address as the runner opens an external target, and `mailto:` in place; the
runner refuses anything else at the click and reports it. It is not drawn when
a term typed into the grid's own server-side search box is what emptied it —
the table and its search box stay, so the term can be cleared. Leave the key
out and an empty grid draws the table's own empty row, as before.

```json
{
Expand All @@ -142,7 +148,8 @@ row declares both (objectstack#20694), so both are bag members and both are in
"emptyState": {
"title": { "en": "No contacts yet", "fr": "Aucun contact" },
"message": "Add one to get started",
"icon": "users"
"icon": "users",
"link": { "label": "How contacts work", "url": "/docs/contacts" }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#12270 — a list page's own `interfaceConfig.emptyState` wins over its
* source view's, on every visualization, and its declared link is drawn.
*
* ## The defect this pins
*
* The spec declares `emptyState` on `InterfacePageConfigSchema` (the list
* view's own `EmptyStateSchema`, by reference): a list page defines its own data
* surface (ADR-0047 revised), so it says what belongs in its empty list. The
* page relayed the source view's `emptyState` only, so a page's own empty state
* parsed and was never drawn: the page showed the view's sentence, or the
* default copy when the view declared none.
*
* The page's own block now wins, WHOLE, over the view's, which stays the
* fallback, as the view's `appearance` and `userFilters` do. The precedence is
* written once, before the visualization is chosen, so it holds on every one;
* these rows read it on `grid` and on `gallery`, the two `ListView` draws an
* empty state for.
*
* ## Why this renders the real `ListView`
*
* The sibling relay pins capture the schema this page hands `ListView`. This
* one renders it, because the card's claim is what a user sees: the page's
* sentence and the page's link, drawn by the real empty branch, the link
* followed through the real action runner under an `ActionProvider` wired as
* the console wires it.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, waitFor, cleanup, fireEvent, screen } from '@testing-library/react';
import React from 'react';
import { ActionProvider } from '@object-ui/react';

vi.mock('react-router-dom', () => ({
useSearchParams: () => [new URLSearchParams(), vi.fn()],
useNavigate: () => vi.fn(),
}));

vi.mock('@object-ui/auth', async (importOriginal) => {
const actual = await (importOriginal as any)();
return { ...actual, useAuth: () => ({}) };
});

let testDataSource: any;
let testObjects: any[];

vi.mock('@object-ui/react', async (importOriginal) => {
const actual = await (importOriginal as any)();
return {
...actual,
useAdapter: () => testDataSource,
useMetadata: () => ({ objects: testObjects }),
};
});

import { InterfaceListPage } from './InterfaceListPage';

const OBJECT_NAME = 'pkg_package';
const VIEW_KEY = 'source';

const PAGE_TITLE = 'No packages yet';
const PAGE_LINK = { label: 'How to publish a package', url: '/apps/studio/docs/publish' };
const VIEW_TITLE = 'Nothing in this view';
const VIEW_MESSAGE = 'The view’s own sentence.';

const dataSourceAnswering = (rows: Record<string, unknown>[]) => ({
find: vi.fn().mockResolvedValue(rows),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn().mockResolvedValue({ name: OBJECT_NAME, fields: { name: { type: 'text' } } }),
});

function objectWith(viewEmptyState: unknown) {
return {
name: OBJECT_NAME,
label: 'Package',
fields: {
name: { type: 'text', label: 'Name' },
// So a whitelisted gallery derives its cover binding, as a real page's does.
cover: { type: 'image', label: 'Cover' },
},
listViews: {
[`${OBJECT_NAME}.${VIEW_KEY}`]: {
name: `${OBJECT_NAME}.${VIEW_KEY}`,
label: 'Source',
type: 'grid',
columns: ['name'],
...(viewEmptyState === undefined ? {} : { emptyState: viewEmptyState }),
},
},
};
}

function mountPage(
visualization: 'grid' | 'gallery',
pageEmptyState: unknown,
viewEmptyState: unknown,
rows: Record<string, unknown>[] = [],
) {
testDataSource = dataSourceAnswering(rows);
testObjects = [objectWith(viewEmptyState)];
const page = {
name: 'my_packages',
label: 'My Packages',
interfaceConfig: {
source: OBJECT_NAME,
sourceView: VIEW_KEY,
recordAction: 'none',
appearance: { allowedVisualizations: [visualization] },
...(pageEmptyState === undefined ? {} : { emptyState: pageEmptyState }),
},
};
const onNavigate = vi.fn();
const onToast = vi.fn();
const result = render(
<ActionProvider onNavigate={onNavigate} onToast={onToast}>
<InterfaceListPage page={page as any} />
</ActionProvider>,
);
return { ...result, onNavigate, onToast };
}

async function emptyStateOf(container: HTMLElement): Promise<HTMLElement> {
await waitFor(() => expect(container.querySelector('[data-testid="empty-state"]')).not.toBeNull());
return container.querySelector('[data-testid="empty-state"]') as HTMLElement;
}

const headingOf = (state: HTMLElement) => state.querySelector('h3')?.textContent ?? null;
const messageOf = (state: HTMLElement) => state.querySelector('p')?.textContent ?? null;
const linkIn = (state: HTMLElement) => state.querySelector('[data-testid="empty-state-link"]') as HTMLElement | null;

beforeEach(() => {
cleanup();
testDataSource = undefined;
testObjects = [];
});
afterEach(() => cleanup());

describe("a list page's own emptyState wins over its source view's (objectui#12270)", () => {
it.each(['grid', 'gallery'] as const)('%s: the page’s own sentence and link are drawn, not the view’s', async (viz) => {
const { container } = mountPage(
viz,
{ title: PAGE_TITLE, link: PAGE_LINK },
{ title: VIEW_TITLE, message: VIEW_MESSAGE },
);
const state = await emptyStateOf(container);
expect(headingOf(state)).toBe(PAGE_TITLE);
// Whole, not merged per member: the view's message does not fill the page's gap.
expect(messageOf(state)).not.toBe(VIEW_MESSAGE);
expect(linkIn(state)?.textContent).toBe(PAGE_LINK.label);
});

it('the page’s link is followed in place, through the host router', async () => {
const { container, onNavigate, onToast } = mountPage('grid', { title: PAGE_TITLE, link: PAGE_LINK }, undefined);
fireEvent.click(linkIn(await emptyStateOf(container))!);
await waitFor(() =>
expect(onNavigate).toHaveBeenCalledWith(PAGE_LINK.url, expect.objectContaining({ external: false, newTab: false })),
);
expect(onToast).not.toHaveBeenCalled();
});

it.each(['grid', 'gallery'] as const)('%s: a page that declares no emptyState keeps the source view’s', async (viz) => {
const { container } = mountPage(viz, undefined, { title: VIEW_TITLE, message: VIEW_MESSAGE });
const state = await emptyStateOf(container);
expect(headingOf(state)).toBe(VIEW_TITLE);
expect(messageOf(state)).toBe(VIEW_MESSAGE);
expect(linkIn(state)).toBeNull();
});

it('CONTROL: with a row, the gallery page mounts the gallery (cards, no table), so the gallery rows above are not the grid twice', async () => {
const { container } = mountPage('gallery', { title: PAGE_TITLE }, undefined, [{ id: 'p1', name: 'billing-kit' }]);
await waitFor(() => expect(screen.getByText('billing-kit')).toBeTruthy());
expect(container.querySelector('table')).toBeNull();
expect(container.querySelector('[data-testid="empty-state"]')).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,9 @@
* `tree`, `chart`, …). A spec view key outside both, with no page slot, is
* `not-inherited`: the page does not take it from a reference the spec
* deprecates. ⚠️ The literal also relays view members outside that line:
* `grouping`, `rowColor`, `pagination`, `searchableFields` and `emptyState`,
* and the view's `appearance` as the page's fallback. They predate this
* `grouping`, `rowColor`, `pagination` and `searchableFields`, and the view's
* `appearance` and `emptyState` as the page's fallbacks (the page's own
* `emptyState` wins since objectui#12270). They predate this
* census, which counts them as relayed and neither retires nor extends them;
* the census below, not this sentence, is what says which members are relayed.
*
Expand Down
8 changes: 7 additions & 1 deletion packages/app-shell/src/views/InterfaceListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,13 @@ export function InterfaceListPage({ page, className, onConfigChange, reserveEdit
rowColor: view.rowColor,
pagination: view.pagination,
searchableFields: view.searchableFields,
emptyState: view.emptyState,
// objectui#12270 — the page's own `interfaceConfig.emptyState` (the spec
// declares it on `InterfacePageConfigSchema` as the list view's own
// `EmptyStateSchema`) wins over the source view's, whole, on every
// visualization: the page states what belongs in its empty list. The
// source view's stays the fallback, as its `appearance` and
// `userFilters` are.
emptyState: cfg.emptyState ?? view.emptyState,
kanban,
calendar,
gallery,
Expand Down
Loading
Loading