From 79076390676d548f6fe073302b93f3ce075a9e46 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 05:21:33 +0000 Subject: [PATCH] test(plugin-list): fold eight more ListView pins that mock nothing onto the shared harness Eight per-issue ListView pins that render the real ListView with no vi.mock become top-level describes in ListView.harness.test.tsx, moved verbatim, each opening with restoreAfterBlock(). The helper now also puts back window.print, which the printButtonSemantics block swaps for a spy. The two blocks that register an object-grid stand-in sit after the blocks that register nothing. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibwT4kjTEGArV4JTj3c --- .../ListView.contractEnvelope-6917.test.tsx | 153 -- .../ListView.emptyStateAccess-25898.test.tsx | 170 --- .../ListView.emptyStateFilteredCopy.test.tsx | 175 --- .../src/__tests__/ListView.harness.test.tsx | 1298 ++++++++++++++++- .../ListView.nameFieldSelect-13116.test.tsx | 162 -- .../ListView.printButtonSemantics.test.tsx | 167 --- ...ListView.refusedSortNotSent-30024.test.tsx | 205 --- ...etSortToDefaultNoProviderFallback.test.tsx | 140 -- .../ListView.switcherChrome-7547.test.tsx | 167 --- 9 files changed, 1291 insertions(+), 1346 deletions(-) delete mode 100644 ui/packages/plugin-list/src/__tests__/ListView.contractEnvelope-6917.test.tsx delete mode 100644 ui/packages/plugin-list/src/__tests__/ListView.emptyStateAccess-25898.test.tsx delete mode 100644 ui/packages/plugin-list/src/__tests__/ListView.emptyStateFilteredCopy.test.tsx delete mode 100644 ui/packages/plugin-list/src/__tests__/ListView.nameFieldSelect-13116.test.tsx delete mode 100644 ui/packages/plugin-list/src/__tests__/ListView.printButtonSemantics.test.tsx delete mode 100644 ui/packages/plugin-list/src/__tests__/ListView.refusedSortNotSent-30024.test.tsx delete mode 100644 ui/packages/plugin-list/src/__tests__/ListView.resetSortToDefaultNoProviderFallback.test.tsx delete mode 100644 ui/packages/plugin-list/src/__tests__/ListView.switcherChrome-7547.test.tsx diff --git a/ui/packages/plugin-list/src/__tests__/ListView.contractEnvelope-6917.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.contractEnvelope-6917.test.tsx deleted file mode 100644 index 7b7e65703d0..00000000000 --- a/ui/packages/plugin-list/src/__tests__/ListView.contractEnvelope-6917.test.tsx +++ /dev/null @@ -1,153 +0,0 @@ -/** - * 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 reads its match total as `QueryResult` DECLARES it — and does NOT - * read `count` (objectui#6917 arm B, following objectui#6840). - * - * `QueryResult` (`@object-ui/types`) declares exactly one count member: - * `total`. Before this pin the fetch effect read - * - * (results as any).total ?? (results as any).count - * - * — the same arm objectui#6840 deleted from `RelatedCountStore`, in a module - * that card was not fenced to touch. It decides a rendered number (the record - * count bar) and, through `serverTotal`, whether the grid pages server-side at - * all. - * - * MEASURED on this tree for objectui#6917 — its OWN producer census. #6840's - * zero is seam-local and must not be carried here: the same sweep read 0 - * producers for `value` at `ObjectView`'s seam and 5 at `extractRecords`' - * seam (objectui#6839) in one and the same pass. - * - * CELL every `find()` producer body in the repo, bracket-scanned - * through chained calls ......................... 592 producers - * CONTROL `data` emitted as an envelope member ......... 312 producers - * CONTROL `total` emitted as an envelope member ......... 150 producers - * SUBJECT `count` emitted as an envelope member ........ 0 producers - * - * The controls sit on the JOIN — same cell, same pass, same extraction — so - * the zero is a reading. Superset sweep too: of the 418 files holding a - * `find()` producer, 25 contain the token `count:` anywhere and not one is an - * envelope member (the DataSource's own sibling `count()` method, row fields, - * i18n interpolation parameters, aggregate function names, React state, DOM - * assertions, comments, and objectui#6840's own refusal pin). - * - * Both adapters' `normalizeQueryResult` fold `count` into `total` below the - * fold; ListView calls `dataSource.find()` strictly above it, so the arm was - * unreachable — exactly where a non-conforming producer keeps working - * unrejected (AGENTS.md #0.1). - * - * ⛔ The fix is the deletion, NOT widening `QueryResult` to bless `count`. - * - * ⚠️ NOT fixed here, and deliberately: this same fetch effect ALSO unwraps rows - * with `data → records → value`. That ladder is the `records`/`value` tolerance - * set owned by objectui#6839's `extractRecords` family, not this card's - * `count`/`value` residue, and objectui#6917 does not enumerate it. Reported - * for #6839 rather than half-repaired here. - */ - -import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; -import { render, cleanup, waitFor, within } from '@testing-library/react'; -import '@testing-library/jest-dom'; -import React from 'react'; - -import { I18nProvider, SchemaRendererProvider } from '@object-ui/react'; -import { ListView } from '../ListView'; -import type { ListViewSchema } from '@object-ui/types'; - -const OBJECT = 'showcase_contact'; -const PAGE_SIZE = 10; -/** Three rows — fewer than the page size, so the row count and any server - * total are free to disagree and the disagreement is the whole measurement. */ -const ROWS = [ - { id: 'c-0', name: 'Ada' }, - { id: 'c-1', name: 'Grace' }, - { id: 'c-2', name: 'Alan' }, -]; - -beforeAll(() => { - if (!Element.prototype.scrollIntoView) { - Element.prototype.scrollIntoView = vi.fn() as any; - } -}); -afterEach(cleanup); - -function makeDataSource(answer: unknown) { - return { - find: vi.fn(async () => answer), - findOne: vi.fn(), - create: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - getObjectSchema: async (name: string) => ({ - name, - fields: { id: { type: 'text' }, name: { type: 'text' } }, - }), - } as any; -} - -const listSchema = (): ListViewSchema => ({ - type: 'list-view', - objectName: OBJECT, - fields: ['name'], - // A page size is what turns on ListView's server-paging branch — the branch - // that reads a server total at all. - pagination: { pageSize: PAGE_SIZE }, -} as unknown as ListViewSchema); - -/** - * Render against a `find()` answering `answer` and return the record-count - * bar's text. Scoped by test id rather than queried globally: the digits of a - * total can collide with row text, and `queryByText` throws on multiple - * matches exactly as it does on none. - */ -async function recordCountText(answer: unknown): Promise { - const ds = makeDataSource(answer); - const { getByTestId } = render( - // Without an English provider the `{{count}}` interpolation never runs and - // the raw template reaches the DOM, which would make the count unassertable. - - - - - , - ); - let bar!: HTMLElement; - await waitFor(() => { - bar = getByTestId('record-count-bar'); - expect(within(bar).getByText(/record/)).toBeInTheDocument(); - }); - return within(bar).getByText(/record/).textContent ?? ''; -} - -describe('ListView find() envelope — objectui#6917', () => { - it('still reads the server-side `total` — the contract\'s count member', async () => { - expect(await recordCountText({ data: ROWS, total: 42 })).toBe('42 records'); - }); - - it('does NOT read `count` — falls back to the honest page-local row count', async () => { - // Before the fix this bar read "7 records" off an undeclared key. It now - // reports what it can actually see rather than legitimising a second - // de-facto contract. - expect(await recordCountText({ data: ROWS, count: 7 })).toBe('3 records'); - }); - - it('does NOT let `count` stand in when `total` is absent, even alongside rows', async () => { - // The caricature guard: a reader that returned any number it could find in - // the envelope would answer "7 records" here. - expect(await recordCountText({ data: ROWS, count: 99 })).toBe('3 records'); - }); - - it('`total` still outranks `count` — unchanged, and the control for the two above', async () => { - // Green before AND after the fix: the arm that was always correct. Its - // presence is what makes the refusals above a reading of THIS deletion - // rather than of a ListView that stopped counting. - expect(await recordCountText({ data: ROWS, total: 42, count: 7 })).toBe('42 records'); - }); -}); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.emptyStateAccess-25898.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.emptyStateAccess-25898.test.tsx deleted file mode 100644 index b2981fe45cd..00000000000 --- a/ui/packages/plugin-list/src/__tests__/ListView.emptyStateAccess-25898.test.tsx +++ /dev/null @@ -1,170 +0,0 @@ -/** - * 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. - */ - -/** - * objectstack#25898 — the list empty state tells "no records you can see" and - * "nothing of yours yet" apart from "no records". - * - * Measured on the console: a sales rep who may read none of 38 cases opened - * the case list and was told "Nothing here yet / Create your first record to - * get started." beside no New button; and every "my …" view (`owner` equals - * the current user) told an owner of nothing "No records match this view's - * filter." - * - * Three cases, chosen by what the list knows: - * - no filter, and the caller holds the create grant: today's first-run copy - * and the add-record action; - * - no filter, and the caller is KNOWN not to hold it (the `listNew` row of the - * affordance-to-grant map, once permissions have loaded): "No records you can - * see", and no add-record action even when the view enables one; - * - the view's declared filter names the signed-in user: "Nothing of yours - * yet", with the action only under the create grant. - * - * SUITE DIRECTION, predicted before running: the read-only and "my view" cases - * are RED against the base commit (first-run copy / view-filter copy, and the - * action drawn for a read-only caller); the create-grant, no-provider and - * plain-filter cases are green in both worlds and pin that the fix leaves them - * alone. - */ - -import * as React from 'react'; -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { render, waitFor, cleanup } from '@testing-library/react'; -import { ListView } from '../ListView'; -import { filterNamesCurrentUser } from '../ListEmptyState'; -import { SchemaRendererProvider, FilterScopeProvider } from '@object-ui/react'; -import { MePermissionsProvider, type MePermissionsAuthenticatedResponse } from '@object-ui/permissions'; -import type { ListViewSchema } from '@object-ui/types'; - -afterEach(cleanup); - -const OBJECT = 'crm_case'; -const USER = 'u-rep'; - -function emptyDataSource() { - return { - find: vi.fn().mockResolvedValue([]), - findOne: vi.fn(), - create: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - }; -} - -const READ_ONLY = { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }; -const FULL = { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true }; - -const envelope = (bits: Record): MePermissionsAuthenticatedResponse => ({ - authenticated: true, - userId: USER, - tenantId: null, - hasAnyQueue: false, - positions: ['member'], - permissionSets: ['member'], - objects: { [OBJECT]: bits }, - fields: {}, -}); - -/** A `list-view` node as an author writes it (FilterNode-array filters). */ -const authored = (node: Record): ListViewSchema => - ({ type: 'list-view', objectName: OBJECT, fields: ['name'], addRecord: { enabled: true }, ...node }) as unknown as ListViewSchema; - -async function emptyState( - schema: ListViewSchema, - { bits, currentUserId }: { bits?: Record; currentUserId?: string } = {}, -): Promise { - const ds = emptyDataSource(); - let tree: React.ReactNode = ( - - {}} /> - - ); - if (currentUserId) tree = {tree}; - if (bits) tree = {tree}; - const { container } = render(<>{tree}); - await waitFor(() => { - expect(container.querySelector('[data-testid="empty-state"]')).not.toBeNull(); - }); - return container.querySelector('[data-testid="empty-state"]') as HTMLElement; -} - -const addAction = (panel: HTMLElement) => panel.querySelector('[data-testid="empty-state-add-record"]'); - -describe('ListView empty state — no access vs no records (objectstack#25898)', () => { - it('a caller without the create grant is told there is nothing they can see, and offered no Create', async () => { - const panel = await emptyState(authored({}), { bits: READ_ONLY }); - expect(panel.textContent).toMatch(/No records you can see/); - expect(panel.textContent).toMatch(/ask your administrator/); - expect(panel.textContent).not.toMatch(/Nothing here yet/); - expect(panel.textContent).not.toMatch(/Create your first record/); - expect(addAction(panel)).toBeNull(); - }); - - it('a caller with the create grant still gets the first-run copy and the action', async () => { - const panel = await emptyState(authored({}), { bits: FULL }); - expect(panel.textContent).toMatch(/Nothing here yet/); - expect(panel.textContent).toMatch(/Create your first record/); - expect(addAction(panel)).not.toBeNull(); - }); - - it('with no permission provider the copy is unchanged (fail-open)', async () => { - const panel = await emptyState(authored({})); - expect(panel.textContent).toMatch(/Nothing here yet/); - expect(addAction(panel)).not.toBeNull(); - }); - - it('a "my" view — its filter resolved to the caller\'s id — says the caller has none yet', async () => { - const panel = await emptyState( - authored({ filter: [['owner', '=', USER]] }), - { bits: FULL, currentUserId: USER }, - ); - expect(panel.textContent).toMatch(/Nothing of yours yet/); - expect(panel.textContent).not.toMatch(/No records match this view/); - expect(addAction(panel)).not.toBeNull(); - }); - - it('a "my" view for a caller without the create grant offers no Create', async () => { - const panel = await emptyState( - authored({ filter: [['owner', '=', USER]] }), - { bits: READ_ONLY, currentUserId: USER }, - ); - expect(panel.textContent).toMatch(/Nothing of yours yet/); - expect(addAction(panel)).toBeNull(); - }); - - it('a filter that does not name the caller keeps the view-filter copy', async () => { - const panel = await emptyState( - authored({ filter: [['status', '=', 'open']] }), - { bits: FULL, currentUserId: USER }, - ); - expect(panel.textContent).toMatch(/No matching records/); - expect(panel.textContent).toMatch(/No records match this view/); - }); -}); - -describe('filterNamesCurrentUser (objectstack#25898)', () => { - it('finds the caller in every filter shape a list receives', () => { - expect(filterNamesCurrentUser([['owner', '=', USER]], USER)).toBe(true); - expect(filterNamesCurrentUser([{ field: 'owner', operator: 'equals', value: USER }], USER)).toBe(true); - expect(filterNamesCurrentUser({ owner: { $eq: USER } }, USER)).toBe(true); - expect(filterNamesCurrentUser(['and', ['status', '=', 'open'], ['owner', '=', USER]], USER)).toBe(true); - }); - - it('reads the unresolved placeholder in both spellings', () => { - expect(filterNamesCurrentUser([['owner', '=', '{current_user_id}']], undefined)).toBe(true); - expect(filterNamesCurrentUser({ owner: '${current_user_id}' }, null)).toBe(true); - }); - - it('answers false for a filter about someone or something else, and with no id to compare', () => { - expect(filterNamesCurrentUser([['owner', '=', 'u-other']], USER)).toBe(false); - expect(filterNamesCurrentUser([['status', '=', 'open']], USER)).toBe(false); - expect(filterNamesCurrentUser([['owner', '=', '']], '')).toBe(false); - expect(filterNamesCurrentUser(undefined, USER)).toBe(false); - expect(filterNamesCurrentUser([['note', '=', 'user-{current_user_id}']], USER)).toBe(false); - }); -}); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.emptyStateFilteredCopy.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.emptyStateFilteredCopy.test.tsx deleted file mode 100644 index 585e5cf40a6..00000000000 --- a/ui/packages/plugin-list/src/__tests__/ListView.emptyStateFilteredCopy.test.tsx +++ /dev/null @@ -1,175 +0,0 @@ -/** - * 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. - */ - -/** - * objectui#4155 — a list emptied BY ITS OWN FILTER must not render the - * first-run copy. - * - * The empty state already distinguished "filtered to empty" from "truly - * empty", but it only counted SESSION filters: the search box, the user-filter - * chips, and the filter panel's own conditions. The view's declared `filter` - * was not counted, so a view returning nothing precisely because it is - * filtered — `status not_in [archived, deleted]`, or a stale stored condition - * — told the user "Nothing here yet / Create your first record" over an object - * full of records. - * - * That is the misleading half of this issue: it reads as data loss or a - * permission problem and sends triage away from the view layer. The copy is - * the diagnosis the user gets, so the two cases must not share one string. - * - * SUITE DIRECTION, predicted before running: `a view filtered to empty says so` - * is RED against `origin/main` (which renders the first-run copy) and green - * after; the other two are green in both worlds — they pin that the fix does - * not swallow the genuine first-run case or the author's own override. - * - * objectui#11687 — the second half: a view filter is not the USER's filter. - * The "your current filters or search" message is said only when the user - * applied one (search, user-filter chips, filter panel); a view emptied by its - * own declared filter alone gets its own message (`list.viewFilterNoMatchesMessage`). - * Measured on the console: Webhooks' Active view, zero rows, nothing applied. - */ - -import * as React from 'react'; -import { describe, it, expect, vi } from 'vitest'; -import { render, waitFor } from '@testing-library/react'; -import { ListView } from '../ListView'; -import { SchemaRendererProvider } from '@object-ui/react'; -import type { ListViewSchema } from '@object-ui/types'; - -/** A data source that answers every query with zero rows. */ -function emptyDataSource() { - return { - find: vi.fn().mockResolvedValue([]), - findOne: vi.fn(), - create: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - }; -} - -async function emptyState( - schema: ListViewSchema, - session: Pick, 'initialFilters' | 'initialSearchTerm'> = {}, -): Promise { - const ds = emptyDataSource(); - const { container } = render( - - - , - ); - await waitFor(() => { - expect(container.querySelector('[data-testid="empty-state"]')).not.toBeNull(); - }); - return container.querySelector('[data-testid="empty-state"]') as HTMLElement; -} - -const BASE: ListViewSchema = { - type: 'list-view', - objectName: 'work_order', - fields: ['name'], -}; - -/** - * A `list-view` node as an AUTHOR writes it, before `normalizeListViewSchema` - * folds the legacy vocabulary onto the spec's (#2890). - * - * `ListViewSchema` describes the CANONICAL surface, so it types `filter` as the - * spec's `ViewFilterRule[]` (`{ field, operator, value }` objects). What - * objectui actually stores and passes to `$filter` is an ObjectQL FilterNode - * array — `[['status', 'not_in', ['archived']]]`, the form used below and the - * form the issue's repro carries. That divergence is stated outright in the - * normalizer's own contract note ("the spec types `filter` as `ViewFilterRule[]` - * … so objectui's field is typed from the spec but used as something else … - * That mismatch is real and out of scope here"), and it is the reason these - * fixtures cannot be re-spelled into the declared shape: re-spelling them would - * stop testing the filter form the renderer actually receives. - * - * So the assertion is deliberately made about pre-normalization input, and the - * cast says exactly that rather than widening `ListViewSchema` to admit both — - * widening is what would re-fork the vocabulary #2890 unified. The legacy input - * vocabulary has no declared type of its own today; filed as #4337. - */ -const authored = (node: Record): ListViewSchema => - node as unknown as ListViewSchema; - -describe('ListView empty state — a filtered view says it is filtered (#4155)', () => { - it('a view filtered to empty says "no matching records", not "nothing here yet"', async () => { - const panel = await emptyState( - authored({ - ...BASE, - // The source-declared filter from the issue's repro. - filter: [['status', 'not_in', ['archived', 'deleted']]], - }), - ); - - expect(panel.textContent).toMatch(/No matching records/i); - expect(panel.textContent).not.toMatch(/Nothing here yet/i); - expect(panel.textContent).not.toMatch(/Create your first record/i); - }); - - // objectui#11687 — the Webhooks "Active" view: its own filter, zero rows, - // and no filter or search from the user. - it('a view filtered to empty names the VIEW\'s filter, not "your current filters or search"', async () => { - const panel = await emptyState( - authored({ ...BASE, filter: [{ field: 'active', operator: 'equals', value: true }] }), - ); - - expect(panel.textContent).toContain('No records match this view’s filter.'); - expect(panel.textContent).not.toMatch(/your current filters or search/i); - expect(panel.textContent).not.toMatch(/Create your first record/i); - }); - - it('a search the user typed still gets the "your current filters or search" copy', async () => { - const panel = await emptyState(BASE, { initialSearchTerm: 'zzz' }); - - expect(panel.textContent).toMatch(/No matching records/i); - expect(panel.textContent).toMatch(/your current filters or search/i); - }); - - it('a filter the user applied over a filtered view is the user\'s, and says so', async () => { - const panel = await emptyState( - authored({ ...BASE, filter: [['status', 'not_in', ['archived']]] }), - { - initialFilters: { - id: 'root', - logic: 'and', - conditions: [{ id: 'c1', field: 'name', operator: 'contains', value: 'zzz' }], - } as never, - }, - ); - - expect(panel.textContent).toMatch(/your current filters or search/i); - expect(panel.textContent).not.toContain('No records match this view’s filter.'); - }); - - it('a genuinely unfiltered empty list still invites the user to create', async () => { - const panel = await emptyState(BASE); - - expect(panel.textContent).toMatch(/Nothing here yet/i); - expect(panel.textContent).not.toMatch(/No matching records/i); - }); - - it('an empty filter array is not "filtered" — it imposes nothing', async () => { - const panel = await emptyState({ ...BASE, filter: [] } as ListViewSchema); - - expect(panel.textContent).toMatch(/Nothing here yet/i); - }); - - it("the author's own emptyState copy still wins over both", async () => { - const panel = await emptyState( - authored({ - ...BASE, - filter: [['status', 'not_in', ['archived']]], - emptyState: { title: 'No open work orders', message: 'Dispatch one to get started.' }, - }), - ); - - expect(panel.textContent).toMatch(/No open work orders/); - expect(panel.textContent).not.toMatch(/No matching records/i); - }); -}); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.harness.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.harness.test.tsx index 8992a6b8fb1..59c9cbacb62 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.harness.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.harness.test.tsx @@ -28,9 +28,13 @@ * - Every block's first line is `restoreAfterBlock()` (below). A former file * got a fresh window and a fresh registry; a block shares both with the * blocks after it, so after its last test the helper puts back what the - * block left: registry entries, a swapped-in `localStorage`, the contents - * of both storages, and the `lang` / `dir` that `I18nProvider` sets on - * ``. The next block starts where its file did. + * block left: registry entries, a swapped-in `localStorage` or + * `window.print`, the contents of both storages, and the `lang` / `dir` + * that `I18nProvider` sets on ``. The next block starts where its + * file did. + * - react-i18next's global instance, which a mounted `I18nProvider` + * installs, is put back after every test by `vitest.setup.i18n-global.ts`, + * so a block that mounts no provider still renders with none. * - `I18nProvider` is imported once, from `@object-ui/i18n`; `@object-ui/react` * re-exports that same binding, which is where some former files took it. * @@ -43,12 +47,21 @@ import React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import { render, waitFor, cleanup, screen, fireEvent, act, within } from '@testing-library/react'; import '@testing-library/jest-dom'; -import { SchemaRendererProvider } from '@object-ui/react'; +import { resolveObjectSortability } from '@objectstack/spec/api'; +import { SchemaRendererProvider, FilterScopeProvider } from '@object-ui/react'; import { I18nProvider } from '@object-ui/i18n'; import { ActiveListRecordProvider } from '@object-ui/components'; -import { ComponentRegistry } from '@object-ui/core'; +import { + ComponentRegistry, + attachObjectSortability, + declaredNameField, + getRecordDisplayName, + recordDisplayValueAt, +} from '@object-ui/core'; +import { MePermissionsProvider, type MePermissionsAuthenticatedResponse } from '@object-ui/permissions'; import type { ListViewSchema } from '@object-ui/types'; -import { ListView } from '../ListView'; +import { ListView, LIST_DEFAULT_TRANSLATIONS } from '../ListView'; +import { filterNamesCurrentUser } from '../ListEmptyState'; /** * Puts back, after the calling block's last test, what the block's hooks and @@ -56,7 +69,8 @@ import { ListView } from '../ListView'; * started from. Measured before this existed: three blocks left stand-in * renderers registered or the real `object-grid` unregistered, one left its * `localStorage` stand-in installed, and a view preference one block saved was - * still in storage for the four blocks after it. + * still in storage for the four blocks after it. `printButtonSemantics` swaps + * in a `window.print` spy, and the helper puts that back too. * * Declared first in the block, so its `beforeAll` runs before the block's own * and its `afterAll` after them (Vitest runs after-hooks in reverse order). @@ -64,9 +78,11 @@ import { ListView } from '../ListView'; function restoreAfterBlock() { let registered = new Map>(); let storage: PropertyDescriptor | undefined; + let print: PropertyDescriptor | undefined; beforeAll(() => { registered = new Map(ComponentRegistry.getAllTypes().map((type) => [type, ComponentRegistry.getConfig(type)])); storage = Object.getOwnPropertyDescriptor(window, 'localStorage'); + print = Object.getOwnPropertyDescriptor(window, 'print'); }); afterAll(() => { for (const type of ComponentRegistry.getAllTypes()) { @@ -82,6 +98,9 @@ function restoreAfterBlock() { ComponentRegistry.register(meta.namespace ? fullType.slice(meta.namespace.length + 1) : fullType, component, meta); } if (storage) Object.defineProperty(window, 'localStorage', storage); + // A block that swaps in a `window.print` spy leaves it installed otherwise. + if (print) Object.defineProperty(window, 'print', print); + else delete (window as { print?: unknown }).print; window.localStorage.clear(); window.sessionStorage.clear(); document.documentElement.removeAttribute('lang'); @@ -173,6 +192,473 @@ describe('ListView.concurrencyTokenProjection-12292', () => { }); }); +// ── ListView.contractEnvelope-6917 ─────────────────────────────────────────── + +/** + * ListView reads its match total as `QueryResult` DECLARES it — and does NOT + * read `count` (objectui#6917 arm B, following objectui#6840). + * + * `QueryResult` (`@object-ui/types`) declares exactly one count member: + * `total`. Before this pin the fetch effect read + * + * (results as any).total ?? (results as any).count + * + * — the same arm objectui#6840 deleted from `RelatedCountStore`, in a module + * that card was not fenced to touch. It decides a rendered number (the record + * count bar) and, through `serverTotal`, whether the grid pages server-side at + * all. + * + * MEASURED on this tree for objectui#6917 — its OWN producer census. #6840's + * zero is seam-local and must not be carried here: the same sweep read 0 + * producers for `value` at `ObjectView`'s seam and 5 at `extractRecords`' + * seam (objectui#6839) in one and the same pass. + * + * CELL every `find()` producer body in the repo, bracket-scanned + * through chained calls ......................... 592 producers + * CONTROL `data` emitted as an envelope member ......... 312 producers + * CONTROL `total` emitted as an envelope member ......... 150 producers + * SUBJECT `count` emitted as an envelope member ........ 0 producers + * + * The controls sit on the JOIN — same cell, same pass, same extraction — so + * the zero is a reading. Superset sweep too: of the 418 files holding a + * `find()` producer, 25 contain the token `count:` anywhere and not one is an + * envelope member (the DataSource's own sibling `count()` method, row fields, + * i18n interpolation parameters, aggregate function names, React state, DOM + * assertions, comments, and objectui#6840's own refusal pin). + * + * Both adapters' `normalizeQueryResult` fold `count` into `total` below the + * fold; ListView calls `dataSource.find()` strictly above it, so the arm was + * unreachable — exactly where a non-conforming producer keeps working + * unrejected (AGENTS.md #0.1). + * + * ⛔ The fix is the deletion, NOT widening `QueryResult` to bless `count`. + * + * ⚠️ NOT fixed here, and deliberately: this same fetch effect ALSO unwraps rows + * with `data → records → value`. That ladder is the `records`/`value` tolerance + * set owned by objectui#6839's `extractRecords` family, not this card's + * `count`/`value` residue, and objectui#6917 does not enumerate it. Reported + * for #6839 rather than half-repaired here. + */ +describe('ListView.contractEnvelope-6917', () => { + restoreAfterBlock(); + + const OBJECT = 'showcase_contact'; + const PAGE_SIZE = 10; + /** Three rows — fewer than the page size, so the row count and any server + * total are free to disagree and the disagreement is the whole measurement. */ + const ROWS = [ + { id: 'c-0', name: 'Ada' }, + { id: 'c-1', name: 'Grace' }, + { id: 'c-2', name: 'Alan' }, + ]; + + beforeAll(() => { + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as any; + } + }); + afterEach(cleanup); + + function makeDataSource(answer: unknown) { + return { + find: vi.fn(async () => answer), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: async (name: string) => ({ + name, + fields: { id: { type: 'text' }, name: { type: 'text' } }, + }), + } as any; + } + + const listSchema = (): ListViewSchema => ({ + type: 'list-view', + objectName: OBJECT, + fields: ['name'], + // A page size is what turns on ListView's server-paging branch — the branch + // that reads a server total at all. + pagination: { pageSize: PAGE_SIZE }, + } as unknown as ListViewSchema); + + /** + * Render against a `find()` answering `answer` and return the record-count + * bar's text. Scoped by test id rather than queried globally: the digits of a + * total can collide with row text, and `queryByText` throws on multiple + * matches exactly as it does on none. + */ + async function recordCountText(answer: unknown): Promise { + const ds = makeDataSource(answer); + const { getByTestId } = render( + // Without an English provider the `{{count}}` interpolation never runs and + // the raw template reaches the DOM, which would make the count unassertable. + + + + + , + ); + let bar!: HTMLElement; + await waitFor(() => { + bar = getByTestId('record-count-bar'); + expect(within(bar).getByText(/record/)).toBeInTheDocument(); + }); + return within(bar).getByText(/record/).textContent ?? ''; + } + + describe('ListView find() envelope — objectui#6917', () => { + it('still reads the server-side `total` — the contract\'s count member', async () => { + expect(await recordCountText({ data: ROWS, total: 42 })).toBe('42 records'); + }); + + it('does NOT read `count` — falls back to the honest page-local row count', async () => { + // Before the fix this bar read "7 records" off an undeclared key. It now + // reports what it can actually see rather than legitimising a second + // de-facto contract. + expect(await recordCountText({ data: ROWS, count: 7 })).toBe('3 records'); + }); + + it('does NOT let `count` stand in when `total` is absent, even alongside rows', async () => { + // The caricature guard: a reader that returned any number it could find in + // the envelope would answer "7 records" here. + expect(await recordCountText({ data: ROWS, count: 99 })).toBe('3 records'); + }); + + it('`total` still outranks `count` — unchanged, and the control for the two above', async () => { + // Green before AND after the fix: the arm that was always correct. Its + // presence is what makes the refusals above a reading of THIS deletion + // rather than of a ListView that stopped counting. + expect(await recordCountText({ data: ROWS, total: 42, count: 7 })).toBe('42 records'); + }); + }); +}); + +// ── ListView.emptyStateAccess-25898 ────────────────────────────────────────── + +/** + * objectstack#25898 — the list empty state tells "no records you can see" and + * "nothing of yours yet" apart from "no records". + * + * Measured on the console: a sales rep who may read none of 38 cases opened + * the case list and was told "Nothing here yet / Create your first record to + * get started." beside no New button; and every "my …" view (`owner` equals + * the current user) told an owner of nothing "No records match this view's + * filter." + * + * Three cases, chosen by what the list knows: + * - no filter, and the caller holds the create grant: today's first-run copy + * and the add-record action; + * - no filter, and the caller is KNOWN not to hold it (the `listNew` row of the + * affordance-to-grant map, once permissions have loaded): "No records you can + * see", and no add-record action even when the view enables one; + * - the view's declared filter names the signed-in user: "Nothing of yours + * yet", with the action only under the create grant. + * + * SUITE DIRECTION, predicted before running: the read-only and "my view" cases + * are RED against the base commit (first-run copy / view-filter copy, and the + * action drawn for a read-only caller); the create-grant, no-provider and + * plain-filter cases are green in both worlds and pin that the fix leaves them + * alone. + */ +describe('ListView.emptyStateAccess-25898', () => { + restoreAfterBlock(); + + afterEach(cleanup); + + const OBJECT = 'crm_case'; + const USER = 'u-rep'; + + function emptyDataSource() { + return { + find: vi.fn().mockResolvedValue([]), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + } + + const READ_ONLY = { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }; + const FULL = { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true }; + + const envelope = (bits: Record): MePermissionsAuthenticatedResponse => ({ + authenticated: true, + userId: USER, + tenantId: null, + hasAnyQueue: false, + positions: ['member'], + permissionSets: ['member'], + objects: { [OBJECT]: bits }, + fields: {}, + }); + + /** A `list-view` node as an author writes it (FilterNode-array filters). */ + const authored = (node: Record): ListViewSchema => + ({ type: 'list-view', objectName: OBJECT, fields: ['name'], addRecord: { enabled: true }, ...node }) as unknown as ListViewSchema; + + async function emptyState( + schema: ListViewSchema, + { bits, currentUserId }: { bits?: Record; currentUserId?: string } = {}, + ): Promise { + const ds = emptyDataSource(); + let tree: React.ReactNode = ( + + {}} /> + + ); + if (currentUserId) tree = {tree}; + if (bits) tree = {tree}; + const { container } = render(<>{tree}); + await waitFor(() => { + expect(container.querySelector('[data-testid="empty-state"]')).not.toBeNull(); + }); + return container.querySelector('[data-testid="empty-state"]') as HTMLElement; + } + + const addAction = (panel: HTMLElement) => panel.querySelector('[data-testid="empty-state-add-record"]'); + + describe('ListView empty state — no access vs no records (objectstack#25898)', () => { + it('a caller without the create grant is told there is nothing they can see, and offered no Create', async () => { + const panel = await emptyState(authored({}), { bits: READ_ONLY }); + expect(panel.textContent).toMatch(/No records you can see/); + expect(panel.textContent).toMatch(/ask your administrator/); + expect(panel.textContent).not.toMatch(/Nothing here yet/); + expect(panel.textContent).not.toMatch(/Create your first record/); + expect(addAction(panel)).toBeNull(); + }); + + it('a caller with the create grant still gets the first-run copy and the action', async () => { + const panel = await emptyState(authored({}), { bits: FULL }); + expect(panel.textContent).toMatch(/Nothing here yet/); + expect(panel.textContent).toMatch(/Create your first record/); + expect(addAction(panel)).not.toBeNull(); + }); + + it('with no permission provider the copy is unchanged (fail-open)', async () => { + const panel = await emptyState(authored({})); + expect(panel.textContent).toMatch(/Nothing here yet/); + expect(addAction(panel)).not.toBeNull(); + }); + + it('a "my" view — its filter resolved to the caller\'s id — says the caller has none yet', async () => { + const panel = await emptyState( + authored({ filter: [['owner', '=', USER]] }), + { bits: FULL, currentUserId: USER }, + ); + expect(panel.textContent).toMatch(/Nothing of yours yet/); + expect(panel.textContent).not.toMatch(/No records match this view/); + expect(addAction(panel)).not.toBeNull(); + }); + + it('a "my" view for a caller without the create grant offers no Create', async () => { + const panel = await emptyState( + authored({ filter: [['owner', '=', USER]] }), + { bits: READ_ONLY, currentUserId: USER }, + ); + expect(panel.textContent).toMatch(/Nothing of yours yet/); + expect(addAction(panel)).toBeNull(); + }); + + it('a filter that does not name the caller keeps the view-filter copy', async () => { + const panel = await emptyState( + authored({ filter: [['status', '=', 'open']] }), + { bits: FULL, currentUserId: USER }, + ); + expect(panel.textContent).toMatch(/No matching records/); + expect(panel.textContent).toMatch(/No records match this view/); + }); + }); + + describe('filterNamesCurrentUser (objectstack#25898)', () => { + it('finds the caller in every filter shape a list receives', () => { + expect(filterNamesCurrentUser([['owner', '=', USER]], USER)).toBe(true); + expect(filterNamesCurrentUser([{ field: 'owner', operator: 'equals', value: USER }], USER)).toBe(true); + expect(filterNamesCurrentUser({ owner: { $eq: USER } }, USER)).toBe(true); + expect(filterNamesCurrentUser(['and', ['status', '=', 'open'], ['owner', '=', USER]], USER)).toBe(true); + }); + + it('reads the unresolved placeholder in both spellings', () => { + expect(filterNamesCurrentUser([['owner', '=', '{current_user_id}']], undefined)).toBe(true); + expect(filterNamesCurrentUser({ owner: '${current_user_id}' }, null)).toBe(true); + }); + + it('answers false for a filter about someone or something else, and with no id to compare', () => { + expect(filterNamesCurrentUser([['owner', '=', 'u-other']], USER)).toBe(false); + expect(filterNamesCurrentUser([['status', '=', 'open']], USER)).toBe(false); + expect(filterNamesCurrentUser([['owner', '=', '']], '')).toBe(false); + expect(filterNamesCurrentUser(undefined, USER)).toBe(false); + expect(filterNamesCurrentUser([['note', '=', 'user-{current_user_id}']], USER)).toBe(false); + }); + }); +}); + +// ── ListView.emptyStateFilteredCopy ────────────────────────────────────────── + +/** + * objectui#4155 — a list emptied BY ITS OWN FILTER must not render the + * first-run copy. + * + * The empty state already distinguished "filtered to empty" from "truly + * empty", but it only counted SESSION filters: the search box, the user-filter + * chips, and the filter panel's own conditions. The view's declared `filter` + * was not counted, so a view returning nothing precisely because it is + * filtered — `status not_in [archived, deleted]`, or a stale stored condition + * — told the user "Nothing here yet / Create your first record" over an object + * full of records. + * + * That is the misleading half of this issue: it reads as data loss or a + * permission problem and sends triage away from the view layer. The copy is + * the diagnosis the user gets, so the two cases must not share one string. + * + * SUITE DIRECTION, predicted before running: `a view filtered to empty says so` + * is RED against `origin/main` (which renders the first-run copy) and green + * after; the other two are green in both worlds — they pin that the fix does + * not swallow the genuine first-run case or the author's own override. + * + * objectui#11687 — the second half: a view filter is not the USER's filter. + * The "your current filters or search" message is said only when the user + * applied one (search, user-filter chips, filter panel); a view emptied by its + * own declared filter alone gets its own message (`list.viewFilterNoMatchesMessage`). + * Measured on the console: Webhooks' Active view, zero rows, nothing applied. + */ +describe('ListView.emptyStateFilteredCopy', () => { + restoreAfterBlock(); + + /** A data source that answers every query with zero rows. */ + function emptyDataSource() { + return { + find: vi.fn().mockResolvedValue([]), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + } + + async function emptyState( + schema: ListViewSchema, + session: Pick, 'initialFilters' | 'initialSearchTerm'> = {}, + ): Promise { + const ds = emptyDataSource(); + const { container } = render( + + + , + ); + await waitFor(() => { + expect(container.querySelector('[data-testid="empty-state"]')).not.toBeNull(); + }); + return container.querySelector('[data-testid="empty-state"]') as HTMLElement; + } + + const BASE: ListViewSchema = { + type: 'list-view', + objectName: 'work_order', + fields: ['name'], + }; + + /** + * A `list-view` node as an AUTHOR writes it, before `normalizeListViewSchema` + * folds the legacy vocabulary onto the spec's (#2890). + * + * `ListViewSchema` describes the CANONICAL surface, so it types `filter` as the + * spec's `ViewFilterRule[]` (`{ field, operator, value }` objects). What + * objectui actually stores and passes to `$filter` is an ObjectQL FilterNode + * array — `[['status', 'not_in', ['archived']]]`, the form used below and the + * form the issue's repro carries. That divergence is stated outright in the + * normalizer's own contract note ("the spec types `filter` as `ViewFilterRule[]` + * … so objectui's field is typed from the spec but used as something else … + * That mismatch is real and out of scope here"), and it is the reason these + * fixtures cannot be re-spelled into the declared shape: re-spelling them would + * stop testing the filter form the renderer actually receives. + * + * So the assertion is deliberately made about pre-normalization input, and the + * cast says exactly that rather than widening `ListViewSchema` to admit both — + * widening is what would re-fork the vocabulary #2890 unified. The legacy input + * vocabulary has no declared type of its own today; filed as #4337. + */ + const authored = (node: Record): ListViewSchema => + node as unknown as ListViewSchema; + + describe('ListView empty state — a filtered view says it is filtered (#4155)', () => { + it('a view filtered to empty says "no matching records", not "nothing here yet"', async () => { + const panel = await emptyState( + authored({ + ...BASE, + // The source-declared filter from the issue's repro. + filter: [['status', 'not_in', ['archived', 'deleted']]], + }), + ); + + expect(panel.textContent).toMatch(/No matching records/i); + expect(panel.textContent).not.toMatch(/Nothing here yet/i); + expect(panel.textContent).not.toMatch(/Create your first record/i); + }); + + // objectui#11687 — the Webhooks "Active" view: its own filter, zero rows, + // and no filter or search from the user. + it('a view filtered to empty names the VIEW\'s filter, not "your current filters or search"', async () => { + const panel = await emptyState( + authored({ ...BASE, filter: [{ field: 'active', operator: 'equals', value: true }] }), + ); + + expect(panel.textContent).toContain('No records match this view’s filter.'); + expect(panel.textContent).not.toMatch(/your current filters or search/i); + expect(panel.textContent).not.toMatch(/Create your first record/i); + }); + + it('a search the user typed still gets the "your current filters or search" copy', async () => { + const panel = await emptyState(BASE, { initialSearchTerm: 'zzz' }); + + expect(panel.textContent).toMatch(/No matching records/i); + expect(panel.textContent).toMatch(/your current filters or search/i); + }); + + it('a filter the user applied over a filtered view is the user\'s, and says so', async () => { + const panel = await emptyState( + authored({ ...BASE, filter: [['status', 'not_in', ['archived']]] }), + { + initialFilters: { + id: 'root', + logic: 'and', + conditions: [{ id: 'c1', field: 'name', operator: 'contains', value: 'zzz' }], + } as never, + }, + ); + + expect(panel.textContent).toMatch(/your current filters or search/i); + expect(panel.textContent).not.toContain('No records match this view’s filter.'); + }); + + it('a genuinely unfiltered empty list still invites the user to create', async () => { + const panel = await emptyState(BASE); + + expect(panel.textContent).toMatch(/Nothing here yet/i); + expect(panel.textContent).not.toMatch(/No matching records/i); + }); + + it('an empty filter array is not "filtered" — it imposes nothing', async () => { + const panel = await emptyState({ ...BASE, filter: [] } as ListViewSchema); + + expect(panel.textContent).toMatch(/Nothing here yet/i); + }); + + it("the author's own emptyState copy still wins over both", async () => { + const panel = await emptyState( + authored({ + ...BASE, + filter: [['status', 'not_in', ['archived']]], + emptyState: { title: 'No open work orders', message: 'Dispatch one to get started.' }, + }), + ); + + expect(panel.textContent).toMatch(/No open work orders/); + expect(panel.textContent).not.toMatch(/No matching records/i); + }); + }); +}); + // ── ListView.emptyVsErrorRole-7132 ────────────────────────────────────────────── /** @@ -777,6 +1263,163 @@ describe('ListView.onRowsChange-26679', () => { }); }); +// ── ListView.printButtonSemantics ──────────────────────────────────────────── + +/** + * objectui#4462 — the list toolbar's Print button says what it does. + * + * ## The defect + * + * The button was a bare `window.print()` with a bare "Print" label and no + * tooltip. Acceptance testers at the reporting project read it as the + * product's "export to PDF" entry point; in headless browsers and several + * embedded WebViews `window.print()` is a silent no-op, so the same button + * also reads as simply broken. The real print/PDF primitive was closed + * NOT_PLANNED as objectstack#1301, so the fix is honesty, not a feature: + * the control keeps working and now states that it opens the BROWSER's print + * dialog and is not a PDF export. + * + * ## ⚠ What this file cannot assert + * + * Nothing here observes the printed PAGE. `@media print` does not apply under + * happy-dom (no layout engine, no media emulation), so the shared print + * stylesheet that makes the resulting page usable is pinned STRUCTURALLY, in + * `packages/app-shell/src/__tests__/print-stylesheet-4462.test.ts`, and that + * file states the same limitation at greater length. This file pins only what + * a DOM assertion can actually see: the button carries the sentence, through + * the i18n channel, on the accessible name and the tooltip. + * + * ## Path under test, and the one it does not cover + * + * Provider-less — no `I18nProvider` — which is `createSafeTranslation`'s + * fallback path and therefore reads `LIST_DEFAULT_TRANSLATIONS`. The + * provider-mounted path reads `en.common.printDialogHint`, and the two are + * held byte-identical by + * `app-shell/src/__tests__/defaults-maps-mirror-en-pack.test.tsx` (#4401), so + * asserting the map row here pins both without mounting `createI18n` — whose + * registration as react-i18next's module-global default survives `cleanup()` + * and would leak into the sibling cases (the reason + * `ListView.overlayTitleI18n` and `…NoProviderFallback` are two files). + * + * ## Direction, predicted before running (#4118) + * + * All four cases fail on `origin/main`: the button has no `title` and no + * `aria-label` there, and `LIST_DEFAULT_TRANSLATIONS` has no + * `common.printDialogHint` row — so the accessible name is just "Print" and + * the raw key would render if the row were dropped. + */ +describe('ListView.printButtonSemantics', () => { + restoreAfterBlock(); + + const HINT_KEY = 'common.printDialogHint'; + + beforeAll(() => { + Object.defineProperty(window, 'localStorage', { + value: (() => { + let store: Record = {}; + return { + getItem: (k: string) => store[k] ?? null, + setItem: (k: string, v: string) => { store[k] = v; }, + clear: () => { store = {}; }, + removeItem: (k: string) => { delete store[k]; }, + }; + })(), + configurable: true, + }); + }); + + function makeDataSource() { + return { + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + } as any; + } + + function renderList(schema: Partial, ds: any) { + return render( + + + , + ); + } + + describe('ListView — Print button semantics (objectui#4462)', () => { + it('carries the print-dialog sentence as its tooltip', async () => { + const ds = makeDataSource(); + renderList({ allowPrinting: true } as any, ds); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + + const btn = screen.getByTestId('print-button'); + const hint = LIST_DEFAULT_TRANSLATIONS[HINT_KEY]; + + expect(btn).toHaveAttribute('title', hint); + // Not the raw key: a missing defaults-map row degrades to the key string, + // which would satisfy a bare "has a title" assertion. + expect(btn.getAttribute('title')).not.toBe(HINT_KEY); + }); + + it('names the disambiguation on its accessible name, not just the tooltip', async () => { + // `title` alone is not exposed to every assistive technology, and it never + // reaches touch users. The accessible name is the one channel that always + // carries it. + const ds = makeDataSource(); + renderList({ allowPrinting: true } as any, ds); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + + const btn = screen.getByTestId('print-button'); + const accessibleName = btn.getAttribute('aria-label') ?? ''; + + expect(accessibleName).toContain(LIST_DEFAULT_TRANSLATIONS['list.print']); + expect(accessibleName).toContain(LIST_DEFAULT_TRANSLATIONS[HINT_KEY]); + }); + + it('says "browser" and "not a PDF export" — the two facts the issue is about', () => { + // Pins MEANING, not just presence: a tooltip reading "Print this view" + // would pass both cases above and leave the misreading exactly where it + // was. Case-insensitive substring, so wording may still be edited. + const hint = LIST_DEFAULT_TRANSLATIONS[HINT_KEY]; + expect(hint).toBeTypeOf('string'); + expect(hint.toLowerCase()).toContain('browser'); + expect(hint.toLowerCase()).toContain('print dialog'); + expect(hint.toLowerCase()).toContain('not a pdf export'); + }); + + it('still opens the browser dialog — no removal, no headless gating', async () => { + // The ruling on #4462 forbids both alternatives the issue offered as + // fallbacks. Clicking must still reach `window.print()`. + const printSpy = vi.fn(); + Object.defineProperty(window, 'print', { value: printSpy, configurable: true }); + + const ds = makeDataSource(); + renderList({ allowPrinting: true } as any, ds); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + + fireEvent.click(screen.getByTestId('print-button')); + expect(printSpy).toHaveBeenCalledTimes(1); + }); + + it('marks the tool cluster for the shared print sheet to hide', async () => { + // The rule lives in `@object-ui/app-shell/styles.css`; this is the marker + // it selects on. The left half of the toolbar (view tabs, active filter + // chips) is deliberately NOT marked — it says which slice of the data is + // on the page. + const ds = makeDataSource(); + const { container } = renderList({ allowPrinting: true } as any, ds); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + + const cluster = container.querySelector('[data-print-hide]'); + expect(cluster).not.toBeNull(); + expect(cluster).toContainElement(screen.getByTestId('print-button')); + }); + }); +}); + // ── ListView.refreshIndicatorLocale-10580 ─────────────────────────────────────── /** @@ -865,6 +1508,358 @@ describe('ListView.refreshIndicatorLocale-10580', () => { }); }); +// ── ListView.refusedSortNotSent-30024 ──────────────────────────────────────── + +/** + * objectstack#30024 — a stored view `sort` the platform's sortability + * projection refuses never reaches the server. + * + * `currentSort` is seeded from the view's `sort`, and the data fetch used to + * map it into `$orderby` unfiltered. The projection's verdict was applied to + * the header display and to the persist path (#6455) only, so a list whose + * stored sort named a refused field asked the server for that sort, and the + * Data API answers it `400 INVALID_SORT`: the list failed its first fetch + * instead of loading. + * + * Two refused fields: a `formula`, which the platform's own resolver + * (`resolveObjectSortability`, the one the REST layer serves from) refuses as + * `virtual-type`, and a `text` field marked `sortable: false`, refused as + * `author-declared` since objectstack#30021. The projection is built by that + * resolver, not hand-written. + * + * Every negative cell sits beside a control: a sortable field's sort is sent + * unchanged, and a deployment that serves no projection sends what it sent + * before. + */ +describe('ListView.refusedSortNotSent-30024', () => { + restoreAfterBlock(); + + const objectDef = { + name: 'crm_opportunity', + label: 'Opportunity', + fields: { + name: { type: 'text', label: 'Name' }, + amount: { type: 'currency', label: 'Amount' }, + expected_revenue: { type: 'formula', label: 'Expected Revenue', expression: 'amount * 2' }, + // A stored `text` field its author marked unsortable: refused by the + // projection as `author-declared` since objectstack#30021. + internal_code: { type: 'text', label: 'Internal Code', sortable: false }, + }, + }; + + /** + * The data source answers as the Data API does: a `$orderby` naming a field + * the projection refuses is a `400 INVALID_SORT`, so a list that sends one + * shows its load error. Any other request answers one row. + */ + const REFUSED = new Set(['expected_revenue', 'internal_code']); + + function makeDataSource(opts: { servesSignal?: boolean } = {}) { + return { + find: vi.fn(async (_object: string, params: any) => { + const orderby = Array.isArray(params?.$orderby) ? params.$orderby : []; + if (orderby.some((item: any) => REFUSED.has(item?.field))) { + throw Object.assign(new Error('Field is not sortable'), { + status: 400, + code: 'INVALID_SORT', + }); + } + return { data: [{ id: 'r1', name: 'Acme deal', amount: 100, expected_revenue: 200 }], total: 1 }; + }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => { + const schema = JSON.parse(JSON.stringify(objectDef)); + if (opts.servesSignal !== false) { + attachObjectSortability(schema, resolveObjectSortability(objectDef)); + } + return schema; + }), + }; + } + + async function renderList( + sort: Array<{ field: string; order: 'asc' | 'desc' }>, + dsOpts: { servesSignal?: boolean } = {}, + ) { + const dataSource = makeDataSource(dsOpts); + const schema: ListViewSchema = { + type: 'list-view', + objectName: 'crm_opportunity', + viewType: 'grid', + columns: ['name', 'amount', 'expected_revenue', 'internal_code'] as any, + sort, + } as any; + render( + + + , + ); + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); + // Let every request the mount made settle before reading the screen. + await Promise.allSettled(dataSource.find.mock.results.map((r: any) => r.value)); + return dataSource; + } + + /** Whether the list drew its load-error panel (what a `400` answer shows). */ + const showsLoadError = () => screen.queryByTestId('list-error-state') !== null; + + async function expectLoaded() { + // Give a rejected request the chance to paint its error panel. + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(showsLoadError()).toBe(false); + } + + /** The `$orderby` of every request the list sent, each as `field order` pairs. */ + function sentOrderBys(ds: ReturnType) { + return ds.find.mock.calls.map((call: any[]) => { + const orderby = call[1]?.$orderby; + return Array.isArray(orderby) + ? orderby.map((item: any) => `${item.field} ${item.order}`) + : orderby; + }); + } + + describe('ListView — a refused stored sort never reaches the server (objectstack#30024)', () => { + it('sanity: the served projection refuses the formula and offers the stored fields', () => { + const served = resolveObjectSortability(objectDef) as { fields: Record }; + expect(served.fields.expected_revenue?.sortable).toBe(false); + expect(served.fields.amount).toEqual({ sortable: true }); + expect(served.fields.name).toEqual({ sortable: true }); + expect(served.fields.internal_code).toEqual({ sortable: false, reason: 'author-declared' }); + }); + + it('sends no sort entry for a stored sort naming a sortable: false field, and the list loads', async () => { + const ds = await renderList([ + { field: 'internal_code', order: 'asc' }, + { field: 'amount', order: 'desc' }, + ]); + const sent = sentOrderBys(ds); + expect(sent.length).toBeGreaterThan(0); + for (const orderby of sent) { + expect(orderby).toEqual(['amount desc']); + } + await expectLoaded(); + }); + + it('sends no sort entry for a stored sort naming only a refused field, and the list loads', async () => { + const ds = await renderList([{ field: 'expected_revenue', order: 'desc' }]); + const sent = sentOrderBys(ds); + expect(sent.length).toBeGreaterThan(0); + for (const orderby of sent) { + expect(orderby).toBeUndefined(); + } + await expectLoaded(); + }); + + it('CONTROL: a sortable field\'s stored sort is sent unchanged', async () => { + const ds = await renderList([{ field: 'amount', order: 'desc' }]); + const sent = sentOrderBys(ds); + expect(sent.length).toBeGreaterThan(0); + for (const orderby of sent) { + expect(orderby).toEqual(['amount desc']); + } + await expectLoaded(); + }); + + it('a mixed sort sends only the allowed entries, in their original order', async () => { + const ds = await renderList([ + { field: 'name', order: 'asc' }, + { field: 'expected_revenue', order: 'desc' }, + { field: 'amount', order: 'desc' }, + ]); + const sent = sentOrderBys(ds); + expect(sent.length).toBeGreaterThan(0); + for (const orderby of sent) { + expect(orderby).toEqual(['name asc', 'amount desc']); + } + await expectLoaded(); + }); + + /** + * The harness's own control: with no projection there is no verdict to + * filter by, so the refused entry is sent as before, and the server double + * answers it `400` — the load-error panel is what the fixed cells above are + * proven NOT to show. + */ + it('CONTROL: with no projection served the stored sort is sent as before, and the 400 shows', async () => { + const ds = await renderList( + [ + { field: 'expected_revenue', order: 'desc' }, + { field: 'amount', order: 'asc' }, + ], + { servesSignal: false }, + ); + const sent = sentOrderBys(ds); + expect(sent.length).toBeGreaterThan(0); + for (const orderby of sent) { + expect(orderby).toEqual(['expected_revenue desc', 'amount asc']); + } + await waitFor(() => expect(showsLoadError()).toBe(true)); + }); + }); +}); + +// ── ListView.switcherChrome-7547 ───────────────────────────────────────────── + +/** + * objectui#7547 — switcher chrome around a single entry. + * + * `showViewSwitcher` reaches this component already decided, and both faces + * that decide it compute the same wrong number: `app-shell/ObjectView` and + * `app-shell/InterfaceListPage` count the LENGTH of + * `appearance.allowedVisualizations`. That is the whitelist BEFORE this + * component intersects it with the ADR-0047 capability gate — so a view + * whitelisting `['grid', 'timeline']` with no timeline block whitelisted two, + * resolved one, and drew the border / dropdown / separator cluster around a + * Grid entry that could not switch to anything. + * + * Neither face can compute it: the gate that answers "does this type resolve?" + * lives here, in `availableViews`. So the predicate is applied at the one site + * holding both halves (`viewSwitcherOffered`), which is also why one change + * covers BOTH doors. + * + * ⚠️ THE DISCRIMINATING PAIR IS THE POINT. A fix that simply stopped drawing + * the switcher would pass every "no chrome" case below, so each of them is + * matched by a control that whitelists the SAME two types and DECLARES the + * second one's binding — chrome must still be drawn there. The two arms differ + * in exactly one thing: whether the whitelisted second type resolves. + * + * REVERSE VERIFICATION — direction predicted before running, then observed: + * restore `{showViewSwitcher && (` at the render site and the "no chrome" arms + * go RED (the cluster reappears around one entry) while every control stays + * GREEN in either world — a resolvable second type was always offered. + */ +describe('ListView.switcherChrome-7547', () => { + restoreAfterBlock(); + + /** objectui#12423 — Display is the toolbar default; this file drives the WIDE form's buttons. */ + const WIDE_TOOLBAR = { compactToolbar: false } as const; + + const makeDataSource = () => + ({ + find: vi.fn().mockResolvedValue([]), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ name: 'task', fields: {} }), + }) as any; + + const BASE = { + type: 'list-view', + objectName: 'task', + viewType: 'grid', + columns: ['name'], + } as const; + + /** + * Is switcher chrome on the page, in EITHER of its two forms? + * + * `ViewSwitcherDropdown` renders a segmented control for 2–4 visualizations and + * a collapsed "List (chevron)" dropdown otherwise — which is why the one-entry + * case this card is about drew the DROPDOWN form: a menu affordance whose menu + * holds a single item. Asking for both forms is what keeps these cases about + * "was a switcher offered", not about which shape it took. + */ + const anySwitcher = () => + screen.queryByTestId('view-switcher-segmented') ?? screen.queryByTestId('view-switcher-dropdown'); + + /** + * Mount a view with the switcher ENABLED by the host — the state both app-shell + * faces stamp — and report whether the switcher chrome was drawn. + */ + const chromeDrawn = (view: Record) => { + const dataSource = makeDataSource(); + render( + + + , + ); + return Boolean(anySwitcher()); + }; + + describe('the switcher is drawn for the RESOLVED list, not the whitelist (objectui#7547)', () => { + it('draws NO chrome for `[grid, timeline]` with no timeline block', () => { + // THE DEFECT, verbatim from the card. Two whitelisted, one resolvable. + expect( + chromeDrawn({ appearance: { allowedVisualizations: ['grid', 'timeline'] } }), + ).toBe(false); + }); + + it('CONTROL: the SAME whitelist WITH a declared timeline axis keeps its chrome', () => { + // The discriminating half. Nothing about this pair differs except whether + // the second whitelisted type resolves, so a fix that just stopped drawing + // the cluster cannot pass both. + expect( + chromeDrawn({ + appearance: { allowedVisualizations: ['grid', 'timeline'] }, + timeline: { startDateField: 'due_date' }, + }), + ).toBe(true); + }); + + it('draws NO chrome for `[grid, kanban]` with no groupBy', () => { + expect( + chromeDrawn({ appearance: { allowedVisualizations: ['grid', 'kanban'] } }), + ).toBe(false); + }); + + it('CONTROL: the same whitelist WITH a declared kanban lane keeps its chrome', () => { + expect( + chromeDrawn({ + appearance: { allowedVisualizations: ['grid', 'kanban'] }, + kanban: { groupByField: 'stage' }, + }), + ).toBe(true); + }); + + it('draws NO chrome when every whitelisted extra type is unbound', () => { + // Three whitelisted, still one resolvable — the length test read three. + expect( + chromeDrawn({ appearance: { allowedVisualizations: ['grid', 'kanban', 'calendar'] } }), + ).toBe(false); + }); + + it('CONTROL: chrome for two resolvable types is untouched by this card', () => { + expect( + chromeDrawn({ + appearance: { allowedVisualizations: ['grid', 'kanban', 'calendar'] }, + kanban: { groupByField: 'stage' }, + calendar: { startDateField: 'due_date' }, + }), + ).toBe(true); + }); + + it('CONTROL: the host can still switch the cluster OFF entirely', () => { + // `viewSwitcherOffered` narrows the author's intent; it never widens it. + const dataSource = makeDataSource(); + render( + + + , + ); + expect(anySwitcher()).toBeNull(); + }); + + it('draws NO chrome for a whitelist of one, as before', () => { + expect(chromeDrawn({ appearance: { allowedVisualizations: ['grid'] } })).toBe(false); + }); + }); +}); + // ── ListView.displayMenuClose-12621 ───────────────────────────────────────────── /** @@ -1097,6 +2092,160 @@ describe('ListView.inPageScroll-12463', () => { }); }); +// ── ListView.nameFieldSelect-13116 ─────────────────────────────────────────── + +/** + * objectui#13116 — an ungrouped list asks for the object's declared + * `nameField`. + * + * The bulk confirm step (`plugin-grid` `BulkActionDialog`, objectui#13107) + * names each affected record from the rows the grid holds: the declared + * `nameField` value when the row carries it, else the resolver's + * `Record #ID` floor. An ungrouped list's rows come from `ListView`'s own + * fetch, and its `$select` harvest had no arm for `nameField`, so a list whose + * columns omit it fetched rows without it and the step listed `Record #ID`. + * + * A grouped grid that owns its fetch projects through `ObjectGrid`'s + * `getSelectFields`, pinned with the real dialog in plugin-grid's + * `bulkConfirmNameField-13107.test.tsx`. plugin-list does not depend on + * plugin-grid, so a stub grid stands in here and the confirm step's name is + * asked of the rule that step applies, against the rows the grid is handed: + * the declared value via `recordDisplayValueAt(row, declaredNameField(def))`, + * then `getRecordDisplayName`. + * + * What is pinned: + * - a formula `nameField` that is not a column is in `$select`, is not drawn + * as a column, and the rows handed to the grid name each record; + * - control: an object with no `nameField` sends the same `$select` as + * before; + * - control: a `nameField` that is a column is sent once. + */ +describe('ListView.nameFieldSelect-13116', () => { + restoreAfterBlock(); + + const CONTACT = 'crm_contact'; + const NOTE = 'crm_note'; + + const contactDef = { + name: CONTACT, + label: 'Contact', + nameField: 'full_name', + fields: { + id: { name: 'id', type: 'text' }, + first_name: { name: 'first_name', type: 'text', label: 'First Name' }, + last_name: { name: 'last_name', type: 'text', label: 'Last Name' }, + title: { name: 'title', type: 'text', label: 'Title' }, + full_name: { name: 'full_name', type: 'formula', label: 'Full Name', expression: "first_name + ' ' + last_name" }, + }, + }; + + // The same shape with no declared pointer: the control. + const noteDef = { + name: NOTE, + label: 'Note', + fields: { + id: { name: 'id', type: 'text' }, + subject: { name: 'subject', type: 'text', label: 'Subject' }, + body: { name: 'body', type: 'text', label: 'Body' }, + full_name: { name: 'full_name', type: 'text', label: 'Full Name' }, + }, + }; + + const CONTACT_ROWS = [ + { id: 'c1', first_name: 'John', last_name: 'Smith', title: 'VP of Engineering', full_name: 'John Smith' }, + { id: 'c2', first_name: 'Marcus', last_name: 'Reed', title: 'Director of Commercial Systems', full_name: 'Marcus Reed' }, + ]; + const NOTE_ROWS = [{ id: 'n1', subject: 'Kickoff', body: 'Agenda', full_name: 'unused' }]; + + let lastGridProps: any = null; + let prevObjectGrid: any; + beforeAll(() => { + prevObjectGrid = ComponentRegistry.get('object-grid'); + ComponentRegistry.register('object-grid', (props: any) => { + lastGridProps = props; + return
; + }); + }); + afterAll(() => { + if (prevObjectGrid) ComponentRegistry.register('object-grid', prevObjectGrid); + else ComponentRegistry.unregister('object-grid'); + }); + + /** Honours `$select` the way a server does: a row carries the selected keys only. */ + const makeDataSource = (def: Record, rows: Array>) => + ({ + find: vi.fn(async (_object: string, params: Record) => { + const select = params?.$select as string[] | undefined; + const data = rows.map((row) => + select ? Object.fromEntries(select.filter((k) => k in row).map((k) => [k, row[k]])) : { ...row }, + ); + return { data, total: data.length }; + }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async (name: string) => (name === def.name ? def : null)), + }) as any; + + async function mount(def: Record, rows: Array>, columns: string[]) { + const dataSource = makeDataSource(def, rows); + render( + + + , + ); + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); + const raw = (dataSource.find.mock.calls.at(-1)?.[1]?.$select as string[] | undefined) ?? []; + return { raw, select: [...raw].sort() }; + } + + /** The rows the grid was handed, once it has been handed some. */ + async function gridRows(): Promise>> { + await waitFor(() => expect(Array.isArray(lastGridProps?.data) && lastGridProps.data.length > 0).toBe(true)); + return lastGridProps.data; + } + + beforeEach(() => { + vi.clearAllMocks(); + lastGridProps = null; + }); + afterEach(() => { + cleanup(); + lastGridProps = null; + }); + + describe('ListView — an ungrouped list asks for the declared nameField (objectui#13116)', () => { + it('asks for a formula nameField no column shows, does not draw it, and the rows name each record', async () => { + const { select } = await mount(contactDef, CONTACT_ROWS, ['first_name', 'last_name', 'title']); + expect(select).toEqual(['first_name', 'full_name', 'id', 'last_name', 'title']); + + // Fetched, never drawn: the grid's columns are the authored three. + const drawn = (lastGridProps?.columns ?? lastGridProps?.fields ?? []) as unknown[]; + expect(JSON.stringify(drawn)).not.toContain('full_name'); + + // The confirm step's rule: the declared value when the row carries it. + const rows = await gridRows(); + expect(rows.map((row) => recordDisplayValueAt(row, declaredNameField(contactDef)))).toEqual(['John Smith', 'Marcus Reed']); + expect(rows.map((row) => getRecordDisplayName(contactDef, row))).toEqual(['John Smith', 'Marcus Reed']); + }); + + it('control: an object with no nameField sends the same $select as before', async () => { + const { select } = await mount(noteDef, NOTE_ROWS, ['subject', 'body']); + expect(select).toEqual(['body', 'id', 'subject']); + }); + + it('control: a nameField that is a column is sent once', async () => { + const { raw } = await mount(contactDef, CONTACT_ROWS, ['full_name', 'title']); + expect(raw.filter((f) => f === 'full_name')).toHaveLength(1); + expect([...raw].sort()).toEqual(['full_name', 'id', 'title']); + }); + }); +}); + // ── ListView.recordDrawerCount-27545 ──────────────────────────────────────────── /** @@ -1193,6 +2342,141 @@ describe('ListView.recordDrawerCount-27545', () => { }); }); +// ── ListView.resetSortToDefaultNoProviderFallback ──────────────────────────── + +/** + * `ListView`'s reset-to-declared-sort control resolves to ENGLISH when no + * `I18nProvider` is mounted — objectui#4396. + * + * `list.resetSortToDefault` is one of the six keys PR #4372's census measured + * as reading **outside** its hook's defaults table with **no inline + * `defaultValue`** at the call site. #4372 taught `createSafeTranslation`'s + * fallback to honour an inline default, which fixed the other 20 outside-table + * keys; these six carry neither, so until this card they fell through to + * `fallbackT`'s last resort — the raw key. The control in the sort popover was + * labelled `list.resetSortToDefault`. + * + * The call site is bare on purpose and stays bare + * (`t('list.resetSortToDefault')`, `ListView.tsx`); the fix is the row in + * `LIST_DEFAULT_TRANSLATIONS`, byte-identical to `en.list.resetSortToDefault`. + * That table is the same one objectui#4294 pinned byte-for-byte one row above, + * for the same reason: on a provider-less host it is this copy that renders. + * + * Direction: this file was RED before the change (the button's accessible name + * was the raw key) and is GREEN after. + * + * ── Why this is its own FILE, not a describe block ──────────────────────── + * `createI18n` calls `instance.use(initReactI18next)`, and `initReactI18next` + * registers that instance as **react-i18next's module-global default**. The + * registration survives unmount and `cleanup()`, so one `` mount + * anywhere in a file silently resolves every later "no provider" render in that + * same file against it — a green-looking file that asserts nothing about the + * fallback. Vitest's `dom` project runs with `isolate: true`, so a file that + * never mounts a provider gets a genuinely clean global. Keep it that way: + * **do not import or mount `I18nProvider` here.** + * + * Folded into the shared harness (objectstack#29469), where other blocks do + * mount an `I18nProvider`: that no longer reaches this block. + * `vitest.setup.i18n-global.ts` (objectui#4514) puts react-i18next's global + * back to the pointer the file inherited after every test, so each test here + * still renders with no global instance at all, exactly as this file did. + */ +describe('ListView.resetSortToDefaultNoProviderFallback', () => { + restoreAfterBlock(); + + const objectDef = { + name: 'projects', + label: 'Projects', + fields: { + name: { type: 'text', label: 'Name' }, + plan_start_date: { type: 'date', label: 'Plan Start Date' }, + status: { type: 'select', label: 'Status' }, + }, + }; + + // Rows matter: with an empty result set ListView renders `DataEmptyState` + // instead of the grid, and the toolbar the sort popover hangs off never mounts. + const ROWS = [ + { id: 'p-1', name: 'Alpha', plan_start_date: '2026-01-01', status: 'open' }, + { id: 'p-2', name: 'Beta', plan_start_date: '2026-02-01', status: 'done' }, + ]; + + /** + * The control renders only when the view DECLARES a sort — with nothing + * declared there is no default to return to, so the fixture must declare one. + */ + const DECLARED_SORT = [ + { field: 'plan_start_date', order: 'asc' }, + { field: 'name', order: 'asc' }, + ]; + + let prevObjectGrid: ReturnType; + + beforeAll(() => { + // plugin-grid is not a dependency of plugin-list (that would be a cycle), so + // the grid is stubbed — the same device the neighbouring sort tests use. + prevObjectGrid = ComponentRegistry.get('object-grid'); + ComponentRegistry.register('object-grid', () =>
); + }); + + afterAll(() => { + if (prevObjectGrid) ComponentRegistry.register('object-grid', prevObjectGrid); + else ComponentRegistry.unregister('object-grid'); + }); + + afterEach(() => cleanup()); + + async function openSortPanel() { + const dataSource = { + find: vi.fn().mockResolvedValue({ data: ROWS, total: ROWS.length }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue(objectDef), + }; + render( + + + , + ); + await waitFor(() => expect(dataSource.getObjectSchema).toHaveBeenCalled()); + fireEvent.click(screen.getByRole('button', { name: /^sort/i })); + await screen.findByText('Sort Records'); + } + + describe('ListView reset-sort control — English fallback with no provider (objectui#4396)', () => { + it('names the reset control in English, never the raw key', async () => { + await openSortPanel(); + + const reset = screen.getByTestId('sort-reset-default'); + expect(reset.textContent).toBe('Reset to view default'); + expect(reset.textContent).not.toBe('list.resetSortToDefault'); + }); + + it('leaves no `list.` raw key anywhere in the sort popover', async () => { + // Non-vacuity: `Sort Records` (already in the table) proves the popover is + // genuinely mounted, so the absence below is a fact about the keys rather + // than about an empty DOM. + await openSortPanel(); + + expect(screen.getByText('Sort Records')).toBeTruthy(); + expect(document.body.textContent).not.toMatch(/list\.[a-zA-Z]/); + }); + }); +}); + // ── ListView.rowAdornment-13330 ───────────────────────────────────────────────── /** diff --git a/ui/packages/plugin-list/src/__tests__/ListView.nameFieldSelect-13116.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.nameFieldSelect-13116.test.tsx deleted file mode 100644 index 240824a8bc4..00000000000 --- a/ui/packages/plugin-list/src/__tests__/ListView.nameFieldSelect-13116.test.tsx +++ /dev/null @@ -1,162 +0,0 @@ -/** - * 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. - */ - -/** - * objectui#13116 — an ungrouped list asks for the object's declared - * `nameField`. - * - * The bulk confirm step (`plugin-grid` `BulkActionDialog`, objectui#13107) - * names each affected record from the rows the grid holds: the declared - * `nameField` value when the row carries it, else the resolver's - * `Record #ID` floor. An ungrouped list's rows come from `ListView`'s own - * fetch, and its `$select` harvest had no arm for `nameField`, so a list whose - * columns omit it fetched rows without it and the step listed `Record #ID`. - * - * A grouped grid that owns its fetch projects through `ObjectGrid`'s - * `getSelectFields`, pinned with the real dialog in plugin-grid's - * `bulkConfirmNameField-13107.test.tsx`. plugin-list does not depend on - * plugin-grid, so a stub grid stands in here and the confirm step's name is - * asked of the rule that step applies, against the rows the grid is handed: - * the declared value via `recordDisplayValueAt(row, declaredNameField(def))`, - * then `getRecordDisplayName`. - * - * What is pinned: - * - a formula `nameField` that is not a column is in `$select`, is not drawn - * as a column, and the rows handed to the grid name each record; - * - control: an object with no `nameField` sends the same `$select` as - * before; - * - control: a `nameField` that is a column is sent once. - */ -import React from 'react'; -import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; -import { render, waitFor, cleanup } from '@testing-library/react'; -import { ComponentRegistry, declaredNameField, getRecordDisplayName, recordDisplayValueAt } from '@object-ui/core'; -import { SchemaRendererProvider } from '@object-ui/react'; -import { ListView } from '../ListView'; - -const CONTACT = 'crm_contact'; -const NOTE = 'crm_note'; - -const contactDef = { - name: CONTACT, - label: 'Contact', - nameField: 'full_name', - fields: { - id: { name: 'id', type: 'text' }, - first_name: { name: 'first_name', type: 'text', label: 'First Name' }, - last_name: { name: 'last_name', type: 'text', label: 'Last Name' }, - title: { name: 'title', type: 'text', label: 'Title' }, - full_name: { name: 'full_name', type: 'formula', label: 'Full Name', expression: "first_name + ' ' + last_name" }, - }, -}; - -// The same shape with no declared pointer: the control. -const noteDef = { - name: NOTE, - label: 'Note', - fields: { - id: { name: 'id', type: 'text' }, - subject: { name: 'subject', type: 'text', label: 'Subject' }, - body: { name: 'body', type: 'text', label: 'Body' }, - full_name: { name: 'full_name', type: 'text', label: 'Full Name' }, - }, -}; - -const CONTACT_ROWS = [ - { id: 'c1', first_name: 'John', last_name: 'Smith', title: 'VP of Engineering', full_name: 'John Smith' }, - { id: 'c2', first_name: 'Marcus', last_name: 'Reed', title: 'Director of Commercial Systems', full_name: 'Marcus Reed' }, -]; -const NOTE_ROWS = [{ id: 'n1', subject: 'Kickoff', body: 'Agenda', full_name: 'unused' }]; - -let lastGridProps: any = null; -let prevObjectGrid: any; -beforeAll(() => { - prevObjectGrid = ComponentRegistry.get('object-grid'); - ComponentRegistry.register('object-grid', (props: any) => { - lastGridProps = props; - return
; - }); -}); -afterAll(() => { - if (prevObjectGrid) ComponentRegistry.register('object-grid', prevObjectGrid); - else ComponentRegistry.unregister('object-grid'); -}); - -/** Honours `$select` the way a server does: a row carries the selected keys only. */ -const makeDataSource = (def: Record, rows: Array>) => - ({ - find: vi.fn(async (_object: string, params: Record) => { - const select = params?.$select as string[] | undefined; - const data = rows.map((row) => - select ? Object.fromEntries(select.filter((k) => k in row).map((k) => [k, row[k]])) : { ...row }, - ); - return { data, total: data.length }; - }), - findOne: vi.fn(), - create: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - getObjectSchema: vi.fn(async (name: string) => (name === def.name ? def : null)), - }) as any; - -async function mount(def: Record, rows: Array>, columns: string[]) { - const dataSource = makeDataSource(def, rows); - render( - - - , - ); - await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); - const raw = (dataSource.find.mock.calls.at(-1)?.[1]?.$select as string[] | undefined) ?? []; - return { raw, select: [...raw].sort() }; -} - -/** The rows the grid was handed, once it has been handed some. */ -async function gridRows(): Promise>> { - await waitFor(() => expect(Array.isArray(lastGridProps?.data) && lastGridProps.data.length > 0).toBe(true)); - return lastGridProps.data; -} - -beforeEach(() => { - vi.clearAllMocks(); - lastGridProps = null; -}); -afterEach(() => { - cleanup(); - lastGridProps = null; -}); - -describe('ListView — an ungrouped list asks for the declared nameField (objectui#13116)', () => { - it('asks for a formula nameField no column shows, does not draw it, and the rows name each record', async () => { - const { select } = await mount(contactDef, CONTACT_ROWS, ['first_name', 'last_name', 'title']); - expect(select).toEqual(['first_name', 'full_name', 'id', 'last_name', 'title']); - - // Fetched, never drawn: the grid's columns are the authored three. - const drawn = (lastGridProps?.columns ?? lastGridProps?.fields ?? []) as unknown[]; - expect(JSON.stringify(drawn)).not.toContain('full_name'); - - // The confirm step's rule: the declared value when the row carries it. - const rows = await gridRows(); - expect(rows.map((row) => recordDisplayValueAt(row, declaredNameField(contactDef)))).toEqual(['John Smith', 'Marcus Reed']); - expect(rows.map((row) => getRecordDisplayName(contactDef, row))).toEqual(['John Smith', 'Marcus Reed']); - }); - - it('control: an object with no nameField sends the same $select as before', async () => { - const { select } = await mount(noteDef, NOTE_ROWS, ['subject', 'body']); - expect(select).toEqual(['body', 'id', 'subject']); - }); - - it('control: a nameField that is a column is sent once', async () => { - const { raw } = await mount(contactDef, CONTACT_ROWS, ['full_name', 'title']); - expect(raw.filter((f) => f === 'full_name')).toHaveLength(1); - expect([...raw].sort()).toEqual(['full_name', 'id', 'title']); - }); -}); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.printButtonSemantics.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.printButtonSemantics.test.tsx deleted file mode 100644 index 9445a64ee97..00000000000 --- a/ui/packages/plugin-list/src/__tests__/ListView.printButtonSemantics.test.tsx +++ /dev/null @@ -1,167 +0,0 @@ -/** - * 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. - */ - -/** - * objectui#4462 — the list toolbar's Print button says what it does. - * - * ## The defect - * - * The button was a bare `window.print()` with a bare "Print" label and no - * tooltip. Acceptance testers at the reporting project read it as the - * product's "export to PDF" entry point; in headless browsers and several - * embedded WebViews `window.print()` is a silent no-op, so the same button - * also reads as simply broken. The real print/PDF primitive was closed - * NOT_PLANNED as objectstack#1301, so the fix is honesty, not a feature: - * the control keeps working and now states that it opens the BROWSER's print - * dialog and is not a PDF export. - * - * ## ⚠ What this file cannot assert - * - * Nothing here observes the printed PAGE. `@media print` does not apply under - * happy-dom (no layout engine, no media emulation), so the shared print - * stylesheet that makes the resulting page usable is pinned STRUCTURALLY, in - * `packages/app-shell/src/__tests__/print-stylesheet-4462.test.ts`, and that - * file states the same limitation at greater length. This file pins only what - * a DOM assertion can actually see: the button carries the sentence, through - * the i18n channel, on the accessible name and the tooltip. - * - * ## Path under test, and the one it does not cover - * - * Provider-less — no `I18nProvider` — which is `createSafeTranslation`'s - * fallback path and therefore reads `LIST_DEFAULT_TRANSLATIONS`. The - * provider-mounted path reads `en.common.printDialogHint`, and the two are - * held byte-identical by - * `app-shell/src/__tests__/defaults-maps-mirror-en-pack.test.tsx` (#4401), so - * asserting the map row here pins both without mounting `createI18n` — whose - * registration as react-i18next's module-global default survives `cleanup()` - * and would leak into the sibling cases (the reason - * `ListView.overlayTitleI18n` and `…NoProviderFallback` are two files). - * - * ## Direction, predicted before running (#4118) - * - * All four cases fail on `origin/main`: the button has no `title` and no - * `aria-label` there, and `LIST_DEFAULT_TRANSLATIONS` has no - * `common.printDialogHint` row — so the accessible name is just "Print" and - * the raw key would render if the row were dropped. - */ - -import { describe, it, expect, vi, beforeAll } from 'vitest'; -import { render, waitFor, screen, fireEvent } from '@testing-library/react'; -import '@testing-library/jest-dom'; -import React from 'react'; -import { ListView, LIST_DEFAULT_TRANSLATIONS } from '../ListView'; -import type { ListViewSchema } from '@object-ui/types'; -import { SchemaRendererProvider } from '@object-ui/react'; - -const HINT_KEY = 'common.printDialogHint'; - -beforeAll(() => { - Object.defineProperty(window, 'localStorage', { - value: (() => { - let store: Record = {}; - return { - getItem: (k: string) => store[k] ?? null, - setItem: (k: string, v: string) => { store[k] = v; }, - clear: () => { store = {}; }, - removeItem: (k: string) => { delete store[k]; }, - }; - })(), - configurable: true, - }); -}); - -function makeDataSource() { - return { - find: vi.fn().mockResolvedValue({ data: [], total: 0 }), - findOne: vi.fn(), - create: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - } as any; -} - -function renderList(schema: Partial, ds: any) { - return render( - - - , - ); -} - -describe('ListView — Print button semantics (objectui#4462)', () => { - it('carries the print-dialog sentence as its tooltip', async () => { - const ds = makeDataSource(); - renderList({ allowPrinting: true } as any, ds); - await waitFor(() => expect(ds.find).toHaveBeenCalled()); - - const btn = screen.getByTestId('print-button'); - const hint = LIST_DEFAULT_TRANSLATIONS[HINT_KEY]; - - expect(btn).toHaveAttribute('title', hint); - // Not the raw key: a missing defaults-map row degrades to the key string, - // which would satisfy a bare "has a title" assertion. - expect(btn.getAttribute('title')).not.toBe(HINT_KEY); - }); - - it('names the disambiguation on its accessible name, not just the tooltip', async () => { - // `title` alone is not exposed to every assistive technology, and it never - // reaches touch users. The accessible name is the one channel that always - // carries it. - const ds = makeDataSource(); - renderList({ allowPrinting: true } as any, ds); - await waitFor(() => expect(ds.find).toHaveBeenCalled()); - - const btn = screen.getByTestId('print-button'); - const accessibleName = btn.getAttribute('aria-label') ?? ''; - - expect(accessibleName).toContain(LIST_DEFAULT_TRANSLATIONS['list.print']); - expect(accessibleName).toContain(LIST_DEFAULT_TRANSLATIONS[HINT_KEY]); - }); - - it('says "browser" and "not a PDF export" — the two facts the issue is about', () => { - // Pins MEANING, not just presence: a tooltip reading "Print this view" - // would pass both cases above and leave the misreading exactly where it - // was. Case-insensitive substring, so wording may still be edited. - const hint = LIST_DEFAULT_TRANSLATIONS[HINT_KEY]; - expect(hint).toBeTypeOf('string'); - expect(hint.toLowerCase()).toContain('browser'); - expect(hint.toLowerCase()).toContain('print dialog'); - expect(hint.toLowerCase()).toContain('not a pdf export'); - }); - - it('still opens the browser dialog — no removal, no headless gating', async () => { - // The ruling on #4462 forbids both alternatives the issue offered as - // fallbacks. Clicking must still reach `window.print()`. - const printSpy = vi.fn(); - Object.defineProperty(window, 'print', { value: printSpy, configurable: true }); - - const ds = makeDataSource(); - renderList({ allowPrinting: true } as any, ds); - await waitFor(() => expect(ds.find).toHaveBeenCalled()); - - fireEvent.click(screen.getByTestId('print-button')); - expect(printSpy).toHaveBeenCalledTimes(1); - }); - - it('marks the tool cluster for the shared print sheet to hide', async () => { - // The rule lives in `@object-ui/app-shell/styles.css`; this is the marker - // it selects on. The left half of the toolbar (view tabs, active filter - // chips) is deliberately NOT marked — it says which slice of the data is - // on the page. - const ds = makeDataSource(); - const { container } = renderList({ allowPrinting: true } as any, ds); - await waitFor(() => expect(ds.find).toHaveBeenCalled()); - - const cluster = container.querySelector('[data-print-hide]'); - expect(cluster).not.toBeNull(); - expect(cluster).toContainElement(screen.getByTestId('print-button')); - }); -}); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.refusedSortNotSent-30024.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.refusedSortNotSent-30024.test.tsx deleted file mode 100644 index f16e0c130cd..00000000000 --- a/ui/packages/plugin-list/src/__tests__/ListView.refusedSortNotSent-30024.test.tsx +++ /dev/null @@ -1,205 +0,0 @@ -/** - * 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. - */ - -/** - * objectstack#30024 — a stored view `sort` the platform's sortability - * projection refuses never reaches the server. - * - * `currentSort` is seeded from the view's `sort`, and the data fetch used to - * map it into `$orderby` unfiltered. The projection's verdict was applied to - * the header display and to the persist path (#6455) only, so a list whose - * stored sort named a refused field asked the server for that sort, and the - * Data API answers it `400 INVALID_SORT`: the list failed its first fetch - * instead of loading. - * - * Two refused fields: a `formula`, which the platform's own resolver - * (`resolveObjectSortability`, the one the REST layer serves from) refuses as - * `virtual-type`, and a `text` field marked `sortable: false`, refused as - * `author-declared` since objectstack#30021. The projection is built by that - * resolver, not hand-written. - * - * Every negative cell sits beside a control: a sortable field's sort is sent - * unchanged, and a deployment that serves no projection sends what it sent - * before. - */ -import { describe, it, expect, vi } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; -import '@testing-library/jest-dom'; -import React from 'react'; -import { resolveObjectSortability } from '@objectstack/spec/api'; -import { attachObjectSortability } from '@object-ui/core'; -import { ListView } from '../ListView'; -import type { ListViewSchema } from '@object-ui/types'; -import { SchemaRendererProvider } from '@object-ui/react'; - -const objectDef = { - name: 'crm_opportunity', - label: 'Opportunity', - fields: { - name: { type: 'text', label: 'Name' }, - amount: { type: 'currency', label: 'Amount' }, - expected_revenue: { type: 'formula', label: 'Expected Revenue', expression: 'amount * 2' }, - // A stored `text` field its author marked unsortable: refused by the - // projection as `author-declared` since objectstack#30021. - internal_code: { type: 'text', label: 'Internal Code', sortable: false }, - }, -}; - -/** - * The data source answers as the Data API does: a `$orderby` naming a field - * the projection refuses is a `400 INVALID_SORT`, so a list that sends one - * shows its load error. Any other request answers one row. - */ -const REFUSED = new Set(['expected_revenue', 'internal_code']); - -function makeDataSource(opts: { servesSignal?: boolean } = {}) { - return { - find: vi.fn(async (_object: string, params: any) => { - const orderby = Array.isArray(params?.$orderby) ? params.$orderby : []; - if (orderby.some((item: any) => REFUSED.has(item?.field))) { - throw Object.assign(new Error('Field is not sortable'), { - status: 400, - code: 'INVALID_SORT', - }); - } - return { data: [{ id: 'r1', name: 'Acme deal', amount: 100, expected_revenue: 200 }], total: 1 }; - }), - findOne: vi.fn(), - create: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - getObjectSchema: vi.fn(async () => { - const schema = JSON.parse(JSON.stringify(objectDef)); - if (opts.servesSignal !== false) { - attachObjectSortability(schema, resolveObjectSortability(objectDef)); - } - return schema; - }), - }; -} - -async function renderList( - sort: Array<{ field: string; order: 'asc' | 'desc' }>, - dsOpts: { servesSignal?: boolean } = {}, -) { - const dataSource = makeDataSource(dsOpts); - const schema: ListViewSchema = { - type: 'list-view', - objectName: 'crm_opportunity', - viewType: 'grid', - columns: ['name', 'amount', 'expected_revenue', 'internal_code'] as any, - sort, - } as any; - render( - - - , - ); - await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); - // Let every request the mount made settle before reading the screen. - await Promise.allSettled(dataSource.find.mock.results.map((r: any) => r.value)); - return dataSource; -} - -/** Whether the list drew its load-error panel (what a `400` answer shows). */ -const showsLoadError = () => screen.queryByTestId('list-error-state') !== null; - -async function expectLoaded() { - // Give a rejected request the chance to paint its error panel. - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(showsLoadError()).toBe(false); -} - -/** The `$orderby` of every request the list sent, each as `field order` pairs. */ -function sentOrderBys(ds: ReturnType) { - return ds.find.mock.calls.map((call: any[]) => { - const orderby = call[1]?.$orderby; - return Array.isArray(orderby) - ? orderby.map((item: any) => `${item.field} ${item.order}`) - : orderby; - }); -} - -describe('ListView — a refused stored sort never reaches the server (objectstack#30024)', () => { - it('sanity: the served projection refuses the formula and offers the stored fields', () => { - const served = resolveObjectSortability(objectDef) as { fields: Record }; - expect(served.fields.expected_revenue?.sortable).toBe(false); - expect(served.fields.amount).toEqual({ sortable: true }); - expect(served.fields.name).toEqual({ sortable: true }); - expect(served.fields.internal_code).toEqual({ sortable: false, reason: 'author-declared' }); - }); - - it('sends no sort entry for a stored sort naming a sortable: false field, and the list loads', async () => { - const ds = await renderList([ - { field: 'internal_code', order: 'asc' }, - { field: 'amount', order: 'desc' }, - ]); - const sent = sentOrderBys(ds); - expect(sent.length).toBeGreaterThan(0); - for (const orderby of sent) { - expect(orderby).toEqual(['amount desc']); - } - await expectLoaded(); - }); - - it('sends no sort entry for a stored sort naming only a refused field, and the list loads', async () => { - const ds = await renderList([{ field: 'expected_revenue', order: 'desc' }]); - const sent = sentOrderBys(ds); - expect(sent.length).toBeGreaterThan(0); - for (const orderby of sent) { - expect(orderby).toBeUndefined(); - } - await expectLoaded(); - }); - - it('CONTROL: a sortable field\'s stored sort is sent unchanged', async () => { - const ds = await renderList([{ field: 'amount', order: 'desc' }]); - const sent = sentOrderBys(ds); - expect(sent.length).toBeGreaterThan(0); - for (const orderby of sent) { - expect(orderby).toEqual(['amount desc']); - } - await expectLoaded(); - }); - - it('a mixed sort sends only the allowed entries, in their original order', async () => { - const ds = await renderList([ - { field: 'name', order: 'asc' }, - { field: 'expected_revenue', order: 'desc' }, - { field: 'amount', order: 'desc' }, - ]); - const sent = sentOrderBys(ds); - expect(sent.length).toBeGreaterThan(0); - for (const orderby of sent) { - expect(orderby).toEqual(['name asc', 'amount desc']); - } - await expectLoaded(); - }); - - /** - * The harness's own control: with no projection there is no verdict to - * filter by, so the refused entry is sent as before, and the server double - * answers it `400` — the load-error panel is what the fixed cells above are - * proven NOT to show. - */ - it('CONTROL: with no projection served the stored sort is sent as before, and the 400 shows', async () => { - const ds = await renderList( - [ - { field: 'expected_revenue', order: 'desc' }, - { field: 'amount', order: 'asc' }, - ], - { servesSignal: false }, - ); - const sent = sentOrderBys(ds); - expect(sent.length).toBeGreaterThan(0); - for (const orderby of sent) { - expect(orderby).toEqual(['expected_revenue desc', 'amount asc']); - } - await waitFor(() => expect(showsLoadError()).toBe(true)); - }); -}); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.resetSortToDefaultNoProviderFallback.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.resetSortToDefaultNoProviderFallback.test.tsx deleted file mode 100644 index 9bba8f3d51f..00000000000 --- a/ui/packages/plugin-list/src/__tests__/ListView.resetSortToDefaultNoProviderFallback.test.tsx +++ /dev/null @@ -1,140 +0,0 @@ -/** - * 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 reset-to-declared-sort control resolves to ENGLISH when no - * `I18nProvider` is mounted — objectui#4396. - * - * `list.resetSortToDefault` is one of the six keys PR #4372's census measured - * as reading **outside** its hook's defaults table with **no inline - * `defaultValue`** at the call site. #4372 taught `createSafeTranslation`'s - * fallback to honour an inline default, which fixed the other 20 outside-table - * keys; these six carry neither, so until this card they fell through to - * `fallbackT`'s last resort — the raw key. The control in the sort popover was - * labelled `list.resetSortToDefault`. - * - * The call site is bare on purpose and stays bare - * (`t('list.resetSortToDefault')`, `ListView.tsx`); the fix is the row in - * `LIST_DEFAULT_TRANSLATIONS`, byte-identical to `en.list.resetSortToDefault`. - * That table is the same one objectui#4294 pinned byte-for-byte one row above, - * for the same reason: on a provider-less host it is this copy that renders. - * - * Direction: this file was RED before the change (the button's accessible name - * was the raw key) and is GREEN after. - * - * ── Why this is its own FILE, not a describe block ──────────────────────── - * `createI18n` calls `instance.use(initReactI18next)`, and `initReactI18next` - * registers that instance as **react-i18next's module-global default**. The - * registration survives unmount and `cleanup()`, so one `` mount - * anywhere in a file silently resolves every later "no provider" render in that - * same file against it — a green-looking file that asserts nothing about the - * fallback. Vitest's `dom` project runs with `isolate: true`, so a file that - * never mounts a provider gets a genuinely clean global. Keep it that way: - * **do not import or mount `I18nProvider` here.** - */ - -import React from 'react'; -import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'; -import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; -import '@testing-library/jest-dom'; -import { ComponentRegistry } from '@object-ui/core'; -import { SchemaRendererProvider } from '@object-ui/react'; -import { ListView } from '../ListView'; -import type { ListViewSchema } from '@object-ui/types'; - -const objectDef = { - name: 'projects', - label: 'Projects', - fields: { - name: { type: 'text', label: 'Name' }, - plan_start_date: { type: 'date', label: 'Plan Start Date' }, - status: { type: 'select', label: 'Status' }, - }, -}; - -// Rows matter: with an empty result set ListView renders `DataEmptyState` -// instead of the grid, and the toolbar the sort popover hangs off never mounts. -const ROWS = [ - { id: 'p-1', name: 'Alpha', plan_start_date: '2026-01-01', status: 'open' }, - { id: 'p-2', name: 'Beta', plan_start_date: '2026-02-01', status: 'done' }, -]; - -/** - * The control renders only when the view DECLARES a sort — with nothing - * declared there is no default to return to, so the fixture must declare one. - */ -const DECLARED_SORT = [ - { field: 'plan_start_date', order: 'asc' }, - { field: 'name', order: 'asc' }, -]; - -let prevObjectGrid: ReturnType; - -beforeAll(() => { - // plugin-grid is not a dependency of plugin-list (that would be a cycle), so - // the grid is stubbed — the same device the neighbouring sort tests use. - prevObjectGrid = ComponentRegistry.get('object-grid'); - ComponentRegistry.register('object-grid', () =>
); -}); - -afterAll(() => { - if (prevObjectGrid) ComponentRegistry.register('object-grid', prevObjectGrid); - else ComponentRegistry.unregister('object-grid'); -}); - -afterEach(() => cleanup()); - -async function openSortPanel() { - const dataSource = { - find: vi.fn().mockResolvedValue({ data: ROWS, total: ROWS.length }), - findOne: vi.fn(), - create: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - getObjectSchema: vi.fn().mockResolvedValue(objectDef), - }; - render( - - - , - ); - await waitFor(() => expect(dataSource.getObjectSchema).toHaveBeenCalled()); - fireEvent.click(screen.getByRole('button', { name: /^sort/i })); - await screen.findByText('Sort Records'); -} - -describe('ListView reset-sort control — English fallback with no provider (objectui#4396)', () => { - it('names the reset control in English, never the raw key', async () => { - await openSortPanel(); - - const reset = screen.getByTestId('sort-reset-default'); - expect(reset.textContent).toBe('Reset to view default'); - expect(reset.textContent).not.toBe('list.resetSortToDefault'); - }); - - it('leaves no `list.` raw key anywhere in the sort popover', async () => { - // Non-vacuity: `Sort Records` (already in the table) proves the popover is - // genuinely mounted, so the absence below is a fact about the keys rather - // than about an empty DOM. - await openSortPanel(); - - expect(screen.getByText('Sort Records')).toBeTruthy(); - expect(document.body.textContent).not.toMatch(/list\.[a-zA-Z]/); - }); -}); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.switcherChrome-7547.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.switcherChrome-7547.test.tsx deleted file mode 100644 index d94008a461e..00000000000 --- a/ui/packages/plugin-list/src/__tests__/ListView.switcherChrome-7547.test.tsx +++ /dev/null @@ -1,167 +0,0 @@ -/** - * 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. - */ - -/** - * objectui#7547 — switcher chrome around a single entry. - * - * `showViewSwitcher` reaches this component already decided, and both faces - * that decide it compute the same wrong number: `app-shell/ObjectView` and - * `app-shell/InterfaceListPage` count the LENGTH of - * `appearance.allowedVisualizations`. That is the whitelist BEFORE this - * component intersects it with the ADR-0047 capability gate — so a view - * whitelisting `['grid', 'timeline']` with no timeline block whitelisted two, - * resolved one, and drew the border / dropdown / separator cluster around a - * Grid entry that could not switch to anything. - * - * Neither face can compute it: the gate that answers "does this type resolve?" - * lives here, in `availableViews`. So the predicate is applied at the one site - * holding both halves (`viewSwitcherOffered`), which is also why one change - * covers BOTH doors. - * - * ⚠️ THE DISCRIMINATING PAIR IS THE POINT. A fix that simply stopped drawing - * the switcher would pass every "no chrome" case below, so each of them is - * matched by a control that whitelists the SAME two types and DECLARES the - * second one's binding — chrome must still be drawn there. The two arms differ - * in exactly one thing: whether the whitelisted second type resolves. - * - * REVERSE VERIFICATION — direction predicted before running, then observed: - * restore `{showViewSwitcher && (` at the render site and the "no chrome" arms - * go RED (the cluster reappears around one entry) while every control stays - * GREEN in either world — a resolvable second type was always offered. - */ - -import React from 'react'; -import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import { ListView } from '../ListView'; -import { SchemaRendererProvider } from '@object-ui/react'; - - -/** objectui#12423 — Display is the toolbar default; this file drives the WIDE form's buttons. */ -const WIDE_TOOLBAR = { compactToolbar: false } as const; - -const makeDataSource = () => - ({ - find: vi.fn().mockResolvedValue([]), - findOne: vi.fn(), - create: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - getObjectSchema: vi.fn().mockResolvedValue({ name: 'task', fields: {} }), - }) as any; - -const BASE = { - type: 'list-view', - objectName: 'task', - viewType: 'grid', - columns: ['name'], -} as const; - -/** - * Is switcher chrome on the page, in EITHER of its two forms? - * - * `ViewSwitcherDropdown` renders a segmented control for 2–4 visualizations and - * a collapsed "List (chevron)" dropdown otherwise — which is why the one-entry - * case this card is about drew the DROPDOWN form: a menu affordance whose menu - * holds a single item. Asking for both forms is what keeps these cases about - * "was a switcher offered", not about which shape it took. - */ -const anySwitcher = () => - screen.queryByTestId('view-switcher-segmented') ?? screen.queryByTestId('view-switcher-dropdown'); - -/** - * Mount a view with the switcher ENABLED by the host — the state both app-shell - * faces stamp — and report whether the switcher chrome was drawn. - */ -const chromeDrawn = (view: Record) => { - const dataSource = makeDataSource(); - render( - - - , - ); - return Boolean(anySwitcher()); -}; - -describe('the switcher is drawn for the RESOLVED list, not the whitelist (objectui#7547)', () => { - it('draws NO chrome for `[grid, timeline]` with no timeline block', () => { - // THE DEFECT, verbatim from the card. Two whitelisted, one resolvable. - expect( - chromeDrawn({ appearance: { allowedVisualizations: ['grid', 'timeline'] } }), - ).toBe(false); - }); - - it('CONTROL: the SAME whitelist WITH a declared timeline axis keeps its chrome', () => { - // The discriminating half. Nothing about this pair differs except whether - // the second whitelisted type resolves, so a fix that just stopped drawing - // the cluster cannot pass both. - expect( - chromeDrawn({ - appearance: { allowedVisualizations: ['grid', 'timeline'] }, - timeline: { startDateField: 'due_date' }, - }), - ).toBe(true); - }); - - it('draws NO chrome for `[grid, kanban]` with no groupBy', () => { - expect( - chromeDrawn({ appearance: { allowedVisualizations: ['grid', 'kanban'] } }), - ).toBe(false); - }); - - it('CONTROL: the same whitelist WITH a declared kanban lane keeps its chrome', () => { - expect( - chromeDrawn({ - appearance: { allowedVisualizations: ['grid', 'kanban'] }, - kanban: { groupByField: 'stage' }, - }), - ).toBe(true); - }); - - it('draws NO chrome when every whitelisted extra type is unbound', () => { - // Three whitelisted, still one resolvable — the length test read three. - expect( - chromeDrawn({ appearance: { allowedVisualizations: ['grid', 'kanban', 'calendar'] } }), - ).toBe(false); - }); - - it('CONTROL: chrome for two resolvable types is untouched by this card', () => { - expect( - chromeDrawn({ - appearance: { allowedVisualizations: ['grid', 'kanban', 'calendar'] }, - kanban: { groupByField: 'stage' }, - calendar: { startDateField: 'due_date' }, - }), - ).toBe(true); - }); - - it('CONTROL: the host can still switch the cluster OFF entirely', () => { - // `viewSwitcherOffered` narrows the author's intent; it never widens it. - const dataSource = makeDataSource(); - render( - - - , - ); - expect(anySwitcher()).toBeNull(); - }); - - it('draws NO chrome for a whitelist of one, as before', () => { - expect(chromeDrawn({ appearance: { allowedVisualizations: ['grid'] } })).toBe(false); - }); -});