diff --git a/.changeset/12270-empty-state-link.md b/.changeset/12270-empty-state-link.md new file mode 100644 index 0000000000..d1bba2c77c --- /dev/null +++ b/.changeset/12270-empty-state-link.md @@ -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. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 2e5287dfef..21ddfd7787 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -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` / diff --git a/content/docs/plugins/plugin-grid.mdx b/content/docs/plugins/plugin-grid.mdx index 1ff516b961..53be972997 100644 --- a/content/docs/plugins/plugin-grid.mdx +++ b/content/docs/plugins/plugin-grid.mdx @@ -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. | @@ -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 { @@ -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" } } } } diff --git a/packages/app-shell/src/views/InterfaceListPage.emptyStatePrecedence-12270.test.tsx b/packages/app-shell/src/views/InterfaceListPage.emptyStatePrecedence-12270.test.tsx new file mode 100644 index 0000000000..56dea8af71 --- /dev/null +++ b/packages/app-shell/src/views/InterfaceListPage.emptyStatePrecedence-12270.test.tsx @@ -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[]) => ({ + 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[] = [], +) { + 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( + + + , + ); + return { ...result, onNavigate, onToast }; +} + +async function emptyStateOf(container: HTMLElement): Promise { + 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(); + }); +}); diff --git a/packages/app-shell/src/views/InterfaceListPage.relayCensus-11572.test.ts b/packages/app-shell/src/views/InterfaceListPage.relayCensus-11572.test.ts index 84b56d0a72..0677cf9ca9 100644 --- a/packages/app-shell/src/views/InterfaceListPage.relayCensus-11572.test.ts +++ b/packages/app-shell/src/views/InterfaceListPage.relayCensus-11572.test.ts @@ -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. * diff --git a/packages/app-shell/src/views/InterfaceListPage.tsx b/packages/app-shell/src/views/InterfaceListPage.tsx index 00a41f16bf..de0b7ab8ed 100644 --- a/packages/app-shell/src/views/InterfaceListPage.tsx +++ b/packages/app-shell/src/views/InterfaceListPage.tsx @@ -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, diff --git a/packages/components/src/__tests__/empty-state-link-12270.test.tsx b/packages/components/src/__tests__/empty-state-link-12270.test.tsx new file mode 100644 index 0000000000..0543372f19 --- /dev/null +++ b/packages/components/src/__tests__/empty-state-link-12270.test.tsx @@ -0,0 +1,214 @@ +/** + * 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. + */ + +/** + * `EmptyStateLink`, the one piece that draws an empty state's declared link + * (objectui#12270). + * + * The spec's `EmptyStateSchema.link` is `{ label, url }`. The three doors that + * draw an empty state (`ListView`, a list page through `ListView`, and + * `ObjectGrid`) each draw the link through this component; their own pins + * mount the real door. This file pins the component's own contract with the + * real ActionRunner, under an `ActionProvider` wired as the console wires it + * (`onNavigate` + `onToast`), and with no provider at all: + * + * - an in-app path goes through the host router, in place; + * - an `https://` address goes out as the runner sends any external target; + * - `mailto:` opens in place, never through the router or a new tab; + * - a value the runner refuses navigates nowhere, and the refusal shown is the + * one a bare runner answers for the same target, read off the runner rather + * than restated here; + * - nothing is judged at render: a `${…}` template, which the spec judges at + * the click, is drawn; + * - the label resolves an inline locale map, and a link with no label text + * draws nothing. + */ + +import { describe, it, expect, vi, beforeEach, afterEach, type Mock, type MockInstance } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import { ActionRunner } from '@object-ui/core'; +import { ActionProvider } from '@object-ui/react'; +import { EmptyStateLink, type EmptyStateLinkSpec } from '../custom/empty-state-link'; + +const LABEL = 'Read the guide'; + +/** What a bare runner answers for a `url` action with this target. */ +async function runnerAnswer(target: string) { + return new ActionRunner({}).execute({ type: 'url', target }); +} + +let originalLocation: Location; +let hrefSet: Mock<(url: string) => void>; +let assignSpy: Mock<(url: string) => void>; +let openSpy: MockInstance; +let warnSpy: MockInstance; + +beforeEach(() => { + originalLocation = window.location; + hrefSet = vi.fn(); + assignSpy = vi.fn(); + Object.defineProperty(window, 'location', { + writable: true, + // The runner opens `mailto:` by assigning `window.location.href`; this + // component follows a provider-less redirect with `location.assign`. + value: { + ...originalLocation, + assign: assignSpy, + set href(v: string) { hrefSet(v); }, + } as unknown as Location, + }); + openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + cleanup(); + openSpy.mockRestore(); + warnSpy.mockRestore(); + Object.defineProperty(window, 'location', { writable: true, value: originalLocation }); +}); + +function renderUnderProvider(link: EmptyStateLinkSpec | undefined, displayLocale = 'en') { + const onNavigate = vi.fn(); + const onToast = vi.fn(); + render( + + + , + ); + return { onNavigate, onToast }; +} + +const click = () => fireEvent.click(screen.getByRole('button', { name: LABEL })); + +/** No sink the link could navigate through was touched. */ +function expectNoNavigation(onNavigate?: Mock) { + if (onNavigate) expect(onNavigate).not.toHaveBeenCalled(); + expect(hrefSet).not.toHaveBeenCalled(); + expect(assignSpy).not.toHaveBeenCalled(); + expect(openSpy).not.toHaveBeenCalled(); +} + +const REFUSED: Array<[string, string]> = [ + ['a protocol-relative //host', '//cdn.example.com/x'], + ['a bare relative value', 'thanks'], + ['a javascript: target', 'javascript:alert(1)'], +]; + +describe('EmptyStateLink under an ActionProvider (objectui#12270)', () => { + it('follows an in-app path through the host router, in place, with no toast', async () => { + const url = '/apps/crm/docs/connect'; + const { onNavigate, onToast } = renderUnderProvider({ label: LABEL, url }); + click(); + + await waitFor(() => + expect(onNavigate).toHaveBeenCalledWith(url, expect.objectContaining({ external: false, newTab: false })), + ); + expect(onNavigate).toHaveBeenCalledTimes(1); + expect(onToast).not.toHaveBeenCalled(); + expect(hrefSet).not.toHaveBeenCalled(); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it('sends an https:// address out as the runner sends an external target', async () => { + const url = 'https://example.com/how-to'; + const { onNavigate, onToast } = renderUnderProvider({ label: LABEL, url }); + click(); + + await waitFor(() => + expect(onNavigate).toHaveBeenCalledWith(url, expect.objectContaining({ external: true, newTab: true })), + ); + expect(onToast).not.toHaveBeenCalled(); + }); + + it('opens mailto: in place, not through the router and not in a new tab', async () => { + const url = 'mailto:support@example.com'; + const { onNavigate, onToast } = renderUnderProvider({ label: LABEL, url }); + click(); + + await waitFor(() => expect(hrefSet).toHaveBeenCalledWith(url)); + expect(hrefSet).toHaveBeenCalledTimes(1); + expect(onNavigate).not.toHaveBeenCalled(); + expect(openSpy).not.toHaveBeenCalled(); + expect(onToast).not.toHaveBeenCalled(); + }); + + it.each(REFUSED)('refuses %s at the click, with the runner\'s refusal, and navigates nowhere', async (_what, url) => { + const expected = await runnerAnswer(url); + expect(expected.success).toBe(false); + expect(expected.error).toBeTruthy(); + + const { onNavigate, onToast } = renderUnderProvider({ label: LABEL, url }); + click(); + + await waitFor(() => + expect(onToast).toHaveBeenCalledWith(expected.error, expect.objectContaining({ type: 'error' })), + ); + expectNoNavigation(onNavigate); + }); + + it('judges nothing at render: a ${…} template, which the spec judges at the click, is drawn', () => { + renderUnderProvider({ label: LABEL, url: '${ctx.origin}/docs' }); + expect(screen.getByRole('button', { name: LABEL })).toBeTruthy(); + }); + + it('resolves an inline label map against the display locale', () => { + // `en` first, so a resolver that fell back to `en` or to the first entry + // would paint English under `zh-CN` and fail the row. + renderUnderProvider({ label: { en: 'Read the guide', 'zh-CN': '阅读指南' }, url: '/docs' }, 'zh-CN'); + expect(screen.getByRole('button', { name: '阅读指南' })).toBeTruthy(); + }); + + it('draws nothing for an absent link, or a label with no text', () => { + renderUnderProvider(undefined); + expect(screen.queryByTestId('empty-state-link')).toBeNull(); + cleanup(); + renderUnderProvider({ label: {}, url: '/docs' }); + expect(screen.queryByTestId('empty-state-link')).toBeNull(); + cleanup(); + // CONTROL: the same mount with a label draws the link. + renderUnderProvider({ label: LABEL, url: '/docs' }); + expect(screen.getByTestId('empty-state-link').textContent).toBe(LABEL); + }); +}); + +describe('EmptyStateLink with no ActionProvider (objectui#12270)', () => { + it('follows the runner\'s redirect hand-off for an in-app path', async () => { + const url = '/apps/crm/docs/connect'; + render(); + click(); + + await waitFor(() => expect(assignSpy).toHaveBeenCalledWith(url)); + expect(assignSpy).toHaveBeenCalledTimes(1); + expect(hrefSet).not.toHaveBeenCalled(); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it('opens an https:// address as the bare runner opens it, in a new tab', async () => { + const url = 'https://example.com/how-to'; + render(); + click(); + + await waitFor(() => expect(openSpy).toHaveBeenCalledWith(url, '_blank', 'noopener,noreferrer')); + expect(assignSpy).not.toHaveBeenCalled(); + expect(hrefSet).not.toHaveBeenCalled(); + }); + + it.each(REFUSED)('refuses %s at the click and logs the runner\'s refusal', async (_what, url) => { + const expected = await runnerAnswer(url); + expect(expected.success).toBe(false); + + render(); + click(); + + await waitFor(() => + expect(warnSpy).toHaveBeenCalledWith('[EmptyStateLink] link not followed', expected.error), + ); + expectNoNavigation(); + }); +}); diff --git a/packages/components/src/custom/empty-state-link.tsx b/packages/components/src/custom/empty-state-link.tsx new file mode 100644 index 0000000000..dd6bbff994 --- /dev/null +++ b/packages/components/src/custom/empty-state-link.tsx @@ -0,0 +1,106 @@ +/** + * 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 one link an empty state names (objectui#12270). + * + * `@objectstack/spec`'s `EmptyStateSchema` declares an optional `link`, + * `{ label, url }`: the one next step an empty list names, such as the how-to + * page that explains what belongs here. The shape is taken by reference, so the + * link reaches every empty state that shape reaches, and each of them draws it + * through this component, ⛔ never a copy of its own: + * + * - a list view's `emptyState`, drawn by `@object-ui/plugin-list`'s `ListView`; + * - a list page's own `interfaceConfig.emptyState`, which that page hands the + * same `ListView`; + * - the `object-grid` block's `emptyState`, drawn by `@object-ui/plugin-grid`'s + * `ObjectGrid`. + * + * ## The click is the action runner's + * + * The link is followed as a `type: 'url'` action through the ActionRunner, so + * what a click may open is the runner's `classifyNavigationTarget` answer, the + * one answer every navigator in the app asks (ruling A+ on objectui#12191). + * ⛔ No scheme test lives here. An in-app path goes through the host's router, + * in place. An `http(s)://` address opens as the runner opens any external + * target. `mailto:` opens the mail client in place. Any other value is refused + * by the runner at the click, which reports its refusal (an error toast under + * the console's provider) and navigates nowhere. The spec judges the url at + * parse, except a template that opens with `${…}`, which it says is judged when + * it is clicked, after interpolation: the runner's click is that judgement, so + * nothing is judged here at render. + * + * Following a link is not feedback-worthy, so it raises no success toast, as + * the html tier's anchor and the dashboard's header link raise none. + * + * ## Without an ActionProvider + * + * `useAction()` then hands over a local runner with no navigation or toast + * handler. That runner hands an admitted in-app path back as `redirect` + * instead of navigating, and reports a refusal to nobody, so this component + * follows the redirect with a page navigation and logs the refusal. + */ + +import * as React from 'react'; +import { resolveI18nLabel, type EmptyState } from '@objectstack/spec/ui'; +import { useAction } from '@object-ui/react'; +import { Button } from '../ui/button'; +import { cn } from '../lib/utils'; + +/** The spec's empty-state link, `{ label, url }` (`EmptyStateSchema.link`). */ +export type EmptyStateLinkSpec = NonNullable; + +export interface EmptyStateLinkProps { + /** The authored `emptyState.link`. Absent, nothing is drawn. */ + link?: EmptyStateLinkSpec; + /** + * The locale the `label` is resolved against when it is an inline locale + * map: the one the caller resolves the empty state's `title` and `message` + * against. A label that resolves to no text draws nothing. + */ + displayLocale: string; + className?: string; + 'data-testid'?: string; +} + +export function EmptyStateLink({ + link, + displayLocale, + className, + 'data-testid': testId = 'empty-state-link', +}: EmptyStateLinkProps) { + const { runner } = useAction(); + const label = resolveI18nLabel(link?.label, displayLocale); + if (!link || !label) return null; + + const follow = async () => { + const result = await runner.execute({ + type: 'url', + target: link.url, + toast: { showOnSuccess: false }, + }); + if (!result.success) { + console.warn('[EmptyStateLink] link not followed', result.error); + return; + } + if (result.redirect) window.location.assign(result.redirect); + }; + + return ( + + ); +} diff --git a/packages/components/src/custom/index.ts b/packages/components/src/custom/index.ts index b5f3253d62..6f02572f0c 100644 --- a/packages/components/src/custom/index.ts +++ b/packages/components/src/custom/index.ts @@ -24,4 +24,5 @@ export * from './shimmer-skeleton'; export * from './RecordTitleChip'; export * from './refresh-indicator'; export * from './view-states'; +export * from './empty-state-link'; export * from './mobile-dialog-content'; diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index 7756b10384..602159c6da 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -200,7 +200,7 @@ const grid: ObjectGridBlockNode = { | `columns` | `string[] \| ListColumn[]` | Field names or column objects — see below. | | `label` | `I18nLabel` | Table caption and export 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[]` | 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. | @@ -229,15 +229,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 { @@ -248,7 +254,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" } } } } diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 22dd1d9e6a..903d55916e 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -55,7 +55,7 @@ import { Popover, PopoverContent, PopoverTrigger, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, RefreshIndicator, - DataEmptyState, resolveIcon, + DataEmptyState, EmptyStateLink, resolveIcon, toast, } from '@object-ui/components'; import { usePullToRefresh } from '@object-ui/mobile'; @@ -6861,8 +6861,13 @@ export const ObjectGrid: React.FC = ({ // // A member left out keeps the grid's default: `DataEmptyState`'s own glyph // (an icon name that resolves to nothing is the same as none), the table's - // "No results found" heading, and no message line. Text only, as the table's - // empty row is — an empty table draws no add-record row either. + // "No results found" heading, and no message line. No add-record action, as + // the table's empty row draws none either. + // + // objectui#12270 — the one action it does draw is the authored `link`, + // `{ label, url }`, through the one shared `EmptyStateLink` that `ListView`'s + // empty state draws it with, so the click is the action runner's navigation + // (`classifyNavigationTarget`) here too. ⛔ No scheme test of its own. // // objectui#11227 — `title` and `message` are the spec's `I18nLabel`: a plain // string or an inline locale map. Each is resolved against the display locale @@ -6889,6 +6894,9 @@ export const ObjectGrid: React.FC = ({ icon={AuthoredEmptyIcon ? : undefined} title={authoredEmptyTitle || t('table.noResults')} description={authoredEmptyMessage || undefined} + action={authoredEmptyState?.link + ? + : undefined} /> ) : gridContent; diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.emptyStateLink-12270.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.emptyStateLink-12270.test.tsx new file mode 100644 index 0000000000..914e69ae27 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.emptyStateLink-12270.test.tsx @@ -0,0 +1,118 @@ +/** + * 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. + * + * `object-grid`'s authored empty state draws its declared `link` + * (objectui#12270). + * + * The spec's `object-grid` row takes the list view's `EmptyStateSchema` by + * reference, so the `link: { label, url }` that schema gained reaches this + * block too. `ObjectGrid` read `title`, `message` and `icon` only, so the link + * parsed and was never drawn. It is now drawn through the one shared + * `EmptyStateLink` that `ListView`'s empty state uses, and the click is the + * action runner's navigation (`classifyNavigationTarget`): ⛔ no scheme test in + * the grid. + * + * Each row mounts the `{ type, properties }` document a page carries through + * the real `SchemaRenderer` and the registered block, over a grid with no + * record, under an `ActionProvider` wired as the console wires it + * (`onNavigate` + `onToast`). A refusal is read off a bare runner for the same + * target, not restated here. The shared component's own rows are + * `empty-state-link-12270.test.tsx` in `@object-ui/components`. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach, beforeEach, type MockInstance } from 'vitest'; +import { render, cleanup, waitFor, fireEvent } from '@testing-library/react'; +import { ActionRunner } from '@object-ui/core'; +import { ActionProvider, SchemaRenderer } from '@object-ui/react'; +// Registers `object-grid`, the block under test. +import '../index'; + +const LINK_LABEL = 'Read the guide'; + +const doc = (emptyState: unknown, items: Record[] = []) => ({ + type: 'object-grid', + properties: { + objectName: 'contacts', + columns: [{ field: 'name', label: 'Name' }], + data: { provider: 'value', items }, + emptyState, + }, +}); + +function mount(emptyState: unknown, items: Record[] = []) { + const onNavigate = vi.fn(); + const onToast = vi.fn(); + const result = render( + + + , + ); + return { ...result, onNavigate, onToast }; +} + +async function emptyStateOf(container: HTMLElement): Promise { + await waitFor(() => expect(container.querySelector('[data-testid="object-grid-empty-state"]')).not.toBeNull()); + return container.querySelector('[data-testid="object-grid-empty-state"]') as HTMLElement; +} + +const linkIn = (state: HTMLElement) => state.querySelector('[data-testid="empty-state-link"]') as HTMLElement | null; + +let openSpy: MockInstance; +beforeEach(() => { + openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); +}); +afterEach(() => { + cleanup(); + openSpy.mockRestore(); +}); + +describe('object-grid draws the declared empty-state link through the action runner (objectui#12270)', () => { + it('an in-app path is drawn with its label and followed in place', async () => { + const url = '/apps/crm/docs/contacts'; + const { container, onNavigate, onToast } = mount({ title: 'No contacts yet', link: { label: LINK_LABEL, url } }); + const state = await emptyStateOf(container); + const link = linkIn(state); + expect(link?.textContent).toBe(LINK_LABEL); + + fireEvent.click(link!); + await waitFor(() => + expect(onNavigate).toHaveBeenCalledWith(url, expect.objectContaining({ external: false, newTab: false })), + ); + expect(onNavigate).toHaveBeenCalledTimes(1); + expect(onToast).not.toHaveBeenCalled(); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it('an https:// link goes out as the runner sends an external target', async () => { + const url = 'https://example.com/how-to'; + const { container, onNavigate } = mount({ link: { label: LINK_LABEL, url } }); + fireEvent.click(linkIn(await emptyStateOf(container))!); + await waitFor(() => + expect(onNavigate).toHaveBeenCalledWith(url, expect.objectContaining({ external: true, newTab: true })), + ); + }); + + it('a value the runner refuses navigates nowhere, and the refusal shown is the runner\'s', async () => { + const url = '//cdn.example.com/x'; + const expected = await new ActionRunner({}).execute({ type: 'url', target: url }); + expect(expected.success).toBe(false); + + const { container, onNavigate, onToast } = mount({ link: { label: LINK_LABEL, url } }); + fireEvent.click(linkIn(await emptyStateOf(container))!); + await waitFor(() => + expect(onToast).toHaveBeenCalledWith(expected.error, expect.objectContaining({ type: 'error' })), + ); + expect(onNavigate).not.toHaveBeenCalled(); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it('CONTROL: an authored empty state with no link draws no link', async () => { + const { container } = mount({ title: 'No contacts yet' }); + expect(linkIn(await emptyStateOf(container))).toBeNull(); + }); +}); diff --git a/packages/plugin-grid/src/index.tsx b/packages/plugin-grid/src/index.tsx index 6d063bea66..ff9102477b 100644 --- a/packages/plugin-grid/src/index.tsx +++ b/packages/plugin-grid/src/index.tsx @@ -312,8 +312,9 @@ const GRID_QUERY_INPUTS: ComponentInput[] = [ // that the spec row declares it, at 17.6.0, as the list view's own // `EmptyStateSchema`). Its `title` and `message` are `I18nLabel` members that // `ObjectGrid` resolves against the display locale. The members are pinned by - // `ObjectGrid.emptyStateI18nLabel-11227.test.tsx`. - { name: 'emptyState', type: 'object', description: 'What the grid draws in place of an empty table, `{ title, message, icon }`: a Lucide `icon` name, a `title` (default: the table\'s own "No results found") and a `message` (default: none). `title` and `message` each accept a plain string or an inline per-locale map, resolved against the display locale. 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 it out and an empty grid draws the table\'s own empty row.' }, + // `ObjectGrid.emptyStateI18nLabel-11227.test.tsx`; its `link` (objectui#12270) + // by `ObjectGrid.emptyStateLink-12270.test.tsx`. + { name: 'emptyState', type: 'object', description: 'What the grid draws in place of an empty table, `{ title, message, icon, link }`: a Lucide `icon` name, a `title` (default: the table\'s own "No results found"), a `message` (default: none) and one `link: { label, url }` the user follows (default: none) — an in-app path, or an `http(s)://` or `mailto:` address, opened as a `url` action opens it. `title`, `message` and the link\'s `label` each accept a plain string or an inline per-locale map, resolved against the display locale. 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 it out and an empty grid draws the table\'s own empty row.' }, // ── grouping and roll-ups ───────────────────────────────────────────────── { name: 'grouping', type: 'object', description: 'Group rows by one or more fields into collapsible sections.' }, { name: 'aggregations', type: 'array', description: 'Per-group roll-ups shown in group headers, `[{ field, type: "sum" | "count" | "avg" | "min" | "max" | "count_distinct" }]`. Needs `grouping` to have anything to roll up.' }, diff --git a/packages/plugin-list/README.md b/packages/plugin-list/README.md index c408da589e..ca07099f12 100644 --- a/packages/plugin-list/README.md +++ b/packages/plugin-list/README.md @@ -246,6 +246,47 @@ const viewTypes: Record, string> = { export { view, richColumns, viewTypes }; ``` +## Empty state + +When the `grid` or `gallery` visualization has no record to show, the view +draws its `emptyState` (the spec's `EmptyState`): a Lucide `icon`, a `title`, a +`message` and one `link: { label, url }` the user follows, such as the how-to +page that explains what belongs in the list (objectui#12270). A member left out +keeps the localized default copy; `title`, `message` and the link's `label` +each take a string or an inline locale map, resolved against the display +locale. + +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. The link +is drawn beside the add-record action when the view's `addRecord` block offers +one; the two are separate, and either, both or neither may be drawn. + +```typescript +import type { ListViewSchema } from '@object-ui/types'; + +const emptyTasks: ListViewSchema = { + type: 'list-view', + objectName: 'tasks', + viewType: 'grid', + columns: ['title'], + emptyState: { + title: 'No open tasks', + message: 'Tasks assigned to you appear here.', + icon: 'inbox', + link: { label: 'How tasks are assigned', url: '/docs/tasks' }, + }, +}; + +export { emptyTasks }; +``` + +A list page (`interfaceConfig` of a `type: 'list'` page) may declare its own +`emptyState` of the same shape. The page's own block wins, whole, over the one +its source view declares, on every visualization; the source view's is the +fallback when the page declares none. + ## Page size and fetch batch A list view asks the server for one window of records. When diff --git a/packages/plugin-list/src/ListEmptyState.tsx b/packages/plugin-list/src/ListEmptyState.tsx index a5e8a0a095..8c995b37a3 100644 --- a/packages/plugin-list/src/ListEmptyState.tsx +++ b/packages/plugin-list/src/ListEmptyState.tsx @@ -26,7 +26,7 @@ */ import * as React from 'react'; -import { Button, DataEmptyState } from '@object-ui/components'; +import { Button, DataEmptyState, EmptyStateLink } 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 @@ -58,6 +58,14 @@ export interface ListEmptyStateProps { * `Inbox` glyph. */ icon?: LucideIcon | null; + /** + * The authored `emptyState.link`, `{ label, url }` (objectui#12270), drawn by + * the one shared `EmptyStateLink` beside the add-record action. The spec + * keeps the two apart: the link is the empty state's own next step, the + * add-record entry point is the view's `addRecord` block, so either, both or + * neither may be drawn. + */ + link?: AuthoredEmptyState['link']; /** * 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. @@ -77,6 +85,7 @@ export function ListEmptyState({ title: authoredTitle, message: authoredMessage, icon, + link, hasUserQuery, hasBaseFilter, onAddRecord, @@ -119,6 +128,17 @@ export function ListEmptyState({ : hasBaseFilter ? t('list.viewFilterNoMatchesMessage') : t('list.firstRunMessage')); + const addRecordAction = onAddRecord ? ( + + ) : undefined; return ( onAddRecord()} - > - - {t('list.addRecord')} - - ) : undefined} + action={link ? ( +
+ {addRecordAction} + +
+ ) : addRecordAction} /> ); } diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index be6e586e88..865a006a14 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -5711,6 +5711,9 @@ export const ListView = React.forwardRef(({ // to the fallback here while resolving elsewhere. What an // unresolvable name draws is the empty state's own decision. icon={resolveIcon(schema.emptyState?.icon)} + // objectui#12270 — the authored `{ label, url }` link, followed + // through the action runner by the one shared `EmptyStateLink`. + link={schema.emptyState?.link} 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 diff --git a/packages/plugin-list/src/__tests__/ListView.emptyStateLink-12270.test.tsx b/packages/plugin-list/src/__tests__/ListView.emptyStateLink-12270.test.tsx new file mode 100644 index 0000000000..02ac7017fd --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.emptyStateLink-12270.test.tsx @@ -0,0 +1,137 @@ +/** + * 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. + */ + +/** + * `ListView`'s empty state draws the view's declared `emptyState.link` + * (objectui#12270). + * + * The spec's `EmptyStateSchema` declares one optional `link`, `{ label, url }`, + * for "one plain sentence and one action" on an empty page. `ListView`'s empty + * branch (the `grid` and `gallery` visualizations) handed `ListEmptyState` the + * `title`, `message` and `icon` and nothing else, so the link parsed and was + * never drawn. It is now drawn through the one shared `EmptyStateLink`, and the + * click is the action runner's navigation (`classifyNavigationTarget`): ⛔ no + * scheme test in `ListView`. + * + * Each row mounts the real `ListView` over a data source that answers no rows, + * under an `ActionProvider` wired as the console wires it (`onNavigate` + + * `onToast`), with the real runner behind it. A refusal is read off the runner + * (what a bare runner answers for the same target), not restated here. The + * shared component's own rows, `mailto:` and the provider-less hand-off among + * them, are `empty-state-link-12270.test.tsx` in `@object-ui/components`. + */ + +import { describe, it, expect, vi, afterEach, beforeEach, type MockInstance } from 'vitest'; +import { render, cleanup, waitFor, fireEvent } from '@testing-library/react'; +import { ActionRunner } from '@object-ui/core'; +import { ActionProvider, SchemaRendererProvider } from '@object-ui/react'; +import type { DataSource, ListViewSchema } from '@object-ui/types'; +import { ListView } from '../ListView'; + +const LINK_LABEL = 'Read the guide'; + +const emptyDataSource = () => ({ + find: vi.fn().mockResolvedValue([]), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), +}); + +async function mountEmpty(viewType: 'grid' | 'gallery', extra: Partial) { + const ds = emptyDataSource(); + const onNavigate = vi.fn(); + const onToast = vi.fn(); + const schema = { + type: 'list-view', + objectName: 'tasks', + viewType, + columns: ['name'], + ...extra, + } as ListViewSchema; + const { container } = render( + + + + + , + ); + await waitFor(() => expect(container.querySelector('[data-testid="empty-state"]')).not.toBeNull()); + const state = container.querySelector('[data-testid="empty-state"]') as HTMLElement; + return { state, onNavigate, onToast }; +} + +const linkIn = (state: HTMLElement) => state.querySelector('[data-testid="empty-state-link"]') as HTMLElement | null; + +let openSpy: MockInstance; +beforeEach(() => { + openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); +}); +afterEach(() => { + cleanup(); + openSpy.mockRestore(); +}); + +describe('ListView draws the declared empty-state link through the action runner (objectui#12270)', () => { + it('grid: the link is drawn beside the add-record action, and an in-app path is followed in place', async () => { + const url = '/apps/crm/docs/connect'; + const { state, onNavigate, onToast } = await mountEmpty('grid', { + addRecord: { enabled: true }, + emptyState: { title: 'No tasks yet', link: { label: LINK_LABEL, url } }, + }); + // The spec keeps the two apart, so both are drawn. + expect(state.querySelector('[data-testid="empty-state-add-record"]')).not.toBeNull(); + const link = linkIn(state); + expect(link?.textContent).toBe(LINK_LABEL); + + fireEvent.click(link!); + await waitFor(() => + expect(onNavigate).toHaveBeenCalledWith(url, expect.objectContaining({ external: false, newTab: false })), + ); + expect(onNavigate).toHaveBeenCalledTimes(1); + expect(onToast).not.toHaveBeenCalled(); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it('gallery: an https:// link goes out as the runner sends an external target', async () => { + const url = 'https://example.com/how-to'; + const { state, onNavigate, onToast } = await mountEmpty('gallery', { + emptyState: { link: { label: LINK_LABEL, url } }, + }); + fireEvent.click(linkIn(state)!); + await waitFor(() => + expect(onNavigate).toHaveBeenCalledWith(url, expect.objectContaining({ external: true, newTab: true })), + ); + expect(onToast).not.toHaveBeenCalled(); + }); + + it('a value the runner refuses navigates nowhere, and the refusal shown is the runner\'s', async () => { + const url = 'thanks'; + const expected = await new ActionRunner({}).execute({ type: 'url', target: url }); + expect(expected.success).toBe(false); + + const { state, onNavigate, onToast } = await mountEmpty('grid', { + emptyState: { link: { label: LINK_LABEL, url } }, + }); + fireEvent.click(linkIn(state)!); + await waitFor(() => + expect(onToast).toHaveBeenCalledWith(expected.error, expect.objectContaining({ type: 'error' })), + ); + expect(onNavigate).not.toHaveBeenCalled(); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it('CONTROL: an empty state that declares no link draws none, and its add-record action as before', async () => { + const { state } = await mountEmpty('grid', { + addRecord: { enabled: true }, + emptyState: { title: 'No tasks yet' }, + }); + expect(linkIn(state)).toBeNull(); + expect(state.querySelector('[data-testid="empty-state-add-record"]')).not.toBeNull(); + }); +});