diff --git a/ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx b/ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx index fd83a6d2ee4..5cbfd9e6835 100644 --- a/ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx +++ b/ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx @@ -578,7 +578,8 @@ describe('the census: every affordance reads the map (objectui#12082)', () => { // own routes and are rows above), and an affordance that only LEADS to a write // — a button that opens a form. The form's submit is a site here, behind the // form rows; the opener itself is pinned by its own runtime test -// (`ObjectView.createGrant-12082.test.tsx` in `@object-ui/plugin-view`). +// (the `ObjectView.createGrant-12082` block of `ObjectView.harness.test.tsx` +// in `@object-ui/plugin-view`). // // One site key can also hide several doors: a component that issues a write // its HOST gates (the import wizard, mounted by the console list and by the AI diff --git a/ui/packages/plugin-view/src/__tests__/ObjectView.calendarBinding-7029.test.tsx b/ui/packages/plugin-view/src/__tests__/ObjectView.calendarBinding-7029.test.tsx deleted file mode 100644 index 565d6f68992..00000000000 --- a/ui/packages/plugin-view/src/__tests__/ObjectView.calendarBinding-7029.test.tsx +++ /dev/null @@ -1,126 +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#7029 — the SECOND route to `ObjectCalendar` invents nothing either. - * - * Ruled on objectstack#13748 (director batch #19, option A): ⛔ either way no - * invented field names. The card measured the console route - * (`app-shell/ObjectView` → `plugin-list/ListView` → `ObjectCalendar`); this - * file covers the other one. `generateViewSchema` runs precisely when no host - * supplied `renderListView` — the authored `object-view` element, which is what - * `examples/schema-catalog`'s object-view fixtures drive — so it bypasses - * `ListView` entirely and carried its OWN copy of the fabrication: - * `startDateField: 'start_date'`, `endDateField: 'end_date'`, - * `titleField: 'name'` for a view that declared none. - * - * Fixing only the console route would have left this one rendering the same - * plausible, fully wrong screen the card exists to remove, and would have made - * the fix's own claim ("no invented field names") false in the repo it was - * merged into. - * - * REVERSE VERIFICATION — direction predicted before running, then observed: - * restore the three `||` floors in this branch and the "invents NO binding" - * case goes RED (it reads the fabricated names) while the declared-config - * CONTROL stays GREEN in either world. - */ - -import { describe, it, expect, vi } from 'vitest'; -import { render, waitFor } from '@testing-library/react'; -import { ObjectView } from '../ObjectView'; -import type { ObjectViewSchema } from '@object-ui/types'; - -/** Every schema the view hands to SchemaRenderer, in order. */ -const rendered: any[] = []; - -vi.mock('@object-ui/react', async (importOriginal) => { - const React = await import('react'); - return { - ...(await importOriginal>()), - SchemaRenderer: ({ schema }: any) => { - rendered.push(schema); - return
{schema?.type}
; - }, - SchemaRendererContext: React.createContext(null), - subscribeDataChanges: () => () => {}, - notifyDataChanged: () => {}, - }; -}); -vi.mock('@object-ui/plugin-grid', async (importOriginal) => ({ - ...(await importOriginal>()), - ObjectGrid: () =>
, -})); -vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ - ...(await importOriginal>()), - ObjectForm: () =>
, -})); - -async function renderCalendarView(view: Record) { - rendered.length = 0; - const ds: any = { - find: vi.fn().mockResolvedValue({ data: [], total: 0 }), - findOne: vi.fn(), - create: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - getObjectSchema: vi.fn().mockResolvedValue({ name: 'crm_leave_request', fields: {} }), - }; - render( - , - ); - await waitFor(() => expect(rendered.length).toBeGreaterThan(0)); - return rendered[rendered.length - 1]; -} - -/** - * The emitted binding. objectstack#25923: it goes out as the node's own - * `calendar` block, never flat (the spec refuses the five flat on - * `object-calendar`). The guard reds if a flat spelling comes back. - */ -const blockOf = (node: any): Record => { - for (const key of ['startDateField', 'endDateField', 'titleField', 'colorField', 'allDayField']) { - expect(node, `flat ${key} on the node`).not.toHaveProperty(key); - } - return node.calendar ?? {}; -}; - -describe('ObjectView.generateViewSchema — calendar restates only a DECLARED binding (objectui#7029)', () => { - it('invents NO binding for a calendar view that declares no config', async () => { - const schema = await renderCalendarView({}); - expect(schema.type).toBe('object-calendar'); - // All three used to be fabricated here. Absent bindings are the only route - // to `ObjectCalendar`'s refusal screen. - expect(blockOf(schema).startDateField).toBeUndefined(); - expect(blockOf(schema).endDateField).toBeUndefined(); - expect(blockOf(schema).titleField).toBeUndefined(); - }); - - it('CONTROL: forwards a declared calendar block unchanged', async () => { - // A correctly configured calendar renders exactly as it did before this - // card — without this case a fix that emitted nothing at all would look - // identical to the fix that was ruled. - const schema = await renderCalendarView({ - calendar: { startDateField: 'start_date', endDateField: 'end_date', titleField: 'subject' }, - }); - expect(schema.type).toBe('object-calendar'); - expect(blockOf(schema).startDateField).toBe('start_date'); - expect(blockOf(schema).endDateField).toBe('end_date'); - expect(blockOf(schema).titleField).toBe('subject'); - }); - - it('CONTROL: a partially declared block keeps its declared half and only that', async () => { - const schema = await renderCalendarView({ calendar: { startDateField: 'start_date' } }); - expect(blockOf(schema).startDateField).toBe('start_date'); - expect(blockOf(schema).endDateField).toBeUndefined(); - expect(blockOf(schema).titleField).toBeUndefined(); - }); -}); diff --git a/ui/packages/plugin-view/src/__tests__/ObjectView.canonicalTableKeys.test.tsx b/ui/packages/plugin-view/src/__tests__/ObjectView.canonicalTableKeys.test.tsx deleted file mode 100644 index 92aa97441ba..00000000000 --- a/ui/packages/plugin-view/src/__tests__/ObjectView.canonicalTableKeys.test.tsx +++ /dev/null @@ -1,470 +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#5102 — the canonical `table` keys take effect. - * - * `ObjectViewSchema.table` is documented as "inherits from ObjectGridSchema", - * but `ObjectView` does not spread it: it forwards a hand-written whitelist of - * keys. That whitelist carried only the DEPRECATED half of four pairs, and - * dropped every canonical successor: - * - * forwarded (legacy) | canonical successor on ObjectGridSchema | forwarded? - * ---------------------|-----------------------------------------|----------- - * pageSize | pagination: PaginationConfig | NO - * selectable | selection: SelectionConfig | NO - * defaultFilters | filter | NO - * defaultSort | sort | NO - * - * ⚠️ objectui#5861 RETIRED the fourth pair's legacy half. `defaultSort` is an - * ADR-0049 tombstone — `@objectstack/spec` 17.3.0 refuses it by name on - * `object-grid` — and every reader dropped it together: ObjectView no longer - * forwards it on the grid path, no longer lowers it on the non-grid fetch or - * into the delegated `renderListView` sort, and ObjectGrid no longer reads it. - * The cells below that pinned it as a WORKING alias were flipped, not deleted: - * each now asserts the key is inert on its path, beside a canonical `sort` - * control on the same path. The other three pairs are untouched. - * - * ⚠️ objectui#12030 RETIRED the third pair's legacy half the same way. - * `@objectstack/spec` 18 tombstones `object-grid.defaultFilters` - * (objectstack#11509), refusing it by name, and `ObjectView` stopped reading - * `table.defaultFilters` on all three paths (the grid hand-off, the non-grid - * fetch, the delegated `renderListView` schema), as `ObjectGrid` stopped - * reading its own. The cells that pinned it as a working alias were flipped - * the objectui#5861 way: each asserts the key is inert on its path, beside a - * canonical `filter` control on the same path. - * - * So an author writing the shape the type recommends — `table: { pagination: - * { pageSize: 25 } }` — got a view that compiled, read correctly, and did - * NOTHING. There was no failure signal anywhere: the key is declared on - * `ObjectGridSchema`, `ObjectGrid` reads it, and only this forwarding hop lost - * it. That silent-success shape is the defect, not the missing feature. - * - * Maintainer ruling of 2026-08-18 (verbatim 「同意」): extend the whitelist so - * the canonical keys take effect, keeping the legacy spellings as WORKING - * aliases. Both halves are pinned here — a fix that quietly retired the legacy - * spelling would break every view authored against today's docs. - * - * PRECEDENCE, when an author writes both: the canonical key wins. Where - * `ObjectView` itself resolves the pair (the non-grid fetch, and the delegated - * `renderListView` schema) that choice is encoded in this file's `||` chains - * and pinned below. On the grid path `ObjectView` forwards BOTH slots of the - * pagination and selection pairs and `ObjectGrid` arbitrates canonical-first - * (`schema.pagination?.pageSize ?? schema.pageSize`; `if (schema.selection - * ?.type) … else if (schema.selectable !== undefined)`). Synthesising a - * `pagination` object out of a legacy `pageSize` would flip `ObjectGrid`'s - * `paginationEnabled` (`schema.pagination !== undefined ? true : …`) for every - * view that only ever wrote the legacy key. - * - * objectui#11880 item 5: the FILTER pair is the exception on the grid path. - * `ObjectView` resolves it itself and hands the winner in `filter` alone, - * never writing `defaultFilters`: `ObjectGrid`'s export and page reset read - * `filter` only, so a value in the legacy slot narrowed the rows but not the - * download. The precedence is unchanged (the canonical key wins unless it - * lowers to nothing), and `ObjectView.gridFilterHandoff-11880.test.tsx` reads - * it through the real grid. - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, waitFor } from '@testing-library/react'; -import { ObjectView } from '../ObjectView'; -import type { ObjectViewSchema } from '@object-ui/types'; - -vi.mock('@object-ui/react', async (importOriginal) => { - const React = await import('react'); - return { - ...(await importOriginal>()), - SchemaRenderer: ({ schema }: any) =>
{schema?.type}
, - SchemaRendererContext: React.createContext(null), - subscribeDataChanges: () => () => {}, - notifyDataChanged: () => {}, - }; -}); - -/** - * The grid the view delegates to, replaced by a probe that records the schema - * it was handed. What reaches this object IS the forwarding whitelist. - */ -const gridSchemas: any[] = []; -vi.mock('@object-ui/plugin-grid', async (importOriginal) => ({ - ...(await importOriginal>()), - ObjectGrid: ({ schema }: any) => { - gridSchemas.push(schema); - return
; - }, -})); -vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ - ...(await importOriginal>()), - ObjectForm: () =>
, -})); - -const mockDataSource = () => ({ - find: vi.fn().mockResolvedValue({ data: [], total: 0 }), - findOne: vi.fn(), - create: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - getObjectSchema: vi.fn().mockResolvedValue({ name: 'task', fields: {} }), -}); - -/** Render on the grid path and return the schema ObjectGrid was handed. */ -function forwardedGridSchema(schema: Partial): any { - gridSchemas.length = 0; - render( - , - ); - return gridSchemas[0]; -} - -/** Render on a NON-grid path (ObjectView fetches for itself) and return `find`. */ -function nonGridFind(schema: Partial) { - const ds = mockDataSource(); - render( - , - ); - return ds.find; -} - -async function queryParams(find: ReturnType) { - await waitFor(() => expect(find).toHaveBeenCalled()); - return find.mock.calls[0][1]; -} - -/** Render through the delegated `renderListView` slot and return its schema. */ -function delegatedSchema(schema: Partial): any { - const seen: any[] = []; - render( - { seen.push(s); return
; }} - />, - ); - return seen[0]; -} - -beforeEach(() => { - vi.clearAllMocks(); - gridSchemas.length = 0; -}); - -describe('grid path: the canonical table keys reach ObjectGrid', () => { - // Each of these four was `undefined` on the forwarded schema before - // objectui#5102 — the value the author wrote never left ObjectView. - it('forwards table.pagination', () => { - const grid = forwardedGridSchema({ table: { pagination: { pageSize: 25 } } as any }); - expect(grid.pagination).toEqual({ pageSize: 25 }); - }); - - it('forwards table.pagination.pageSizeOptions along with it', () => { - const grid = forwardedGridSchema({ - table: { pagination: { pageSize: 25, pageSizeOptions: [25, 50] } } as any, - }); - expect(grid.pagination).toEqual({ pageSize: 25, pageSizeOptions: [25, 50] }); - }); - - it('forwards table.selection', () => { - const grid = forwardedGridSchema({ table: { selection: { type: 'multiple' } } as any }); - expect(grid.selection).toEqual({ type: 'multiple' }); - }); - - it('forwards table.filter', () => { - const grid = forwardedGridSchema({ table: { filter: [['stage', '=', 'won']] } as any }); - expect(grid.filter).toEqual([['stage', '=', 'won']]); - }); - - it('forwards table.sort', () => { - const grid = forwardedGridSchema({ table: { sort: [{ field: 'name', order: 'desc' }] } as any }); - expect(grid.sort).toEqual([{ field: 'name', order: 'desc' }]); - }); - - it('forwards table.sort VERBATIM — even the retired string form (objectui#8221)', () => { - // `ObjectGridSchema.sort` is `SortConfig[]` since decision batch #77; the - // string arm is retired. This delegation is nonetheless VALUE-AGNOSTIC and - // stays that way on purpose: a stored metadata row still reaches it - // carrying the old spelling, and the place that judges the value is the - // shared sink downstream (`ObjectView.sortSink.test.tsx` pins the refusal - // and its diagnostic). Coercing or dropping it here would put a second - // opinion about the sort dialect in the delegation path — the fourth - // dialect this whole chain exists to avoid. - const grid = forwardedGridSchema({ table: { sort: 'name desc' } as any }); - expect(grid.sort).toBe('name desc'); - }); -}); - -describe('grid path: the legacy spellings still work', () => { - it('still forwards table.pageSize', () => { - expect(forwardedGridSchema({ table: { pageSize: 25 } as any }).pageSize).toBe(25); - }); - - it('still forwards table.selectable', () => { - expect(forwardedGridSchema({ table: { selectable: true } as any }).selectable).toBe(true); - }); - - it('no longer reads a retired table.defaultFilters — and does not rescue it into `filter` (objectui#12030)', () => { - // Flipped from `still honours table.defaultFilters, handed over in - // `filter`. Neither slot carries it. - const grid = forwardedGridSchema({ table: { defaultFilters: { status: 'active' } } as any }); - expect(grid.filter).toBeUndefined(); - expect(grid.defaultFilters).toBeUndefined(); - // CONTROL, same path: the canonical spelling still arrives. - expect(forwardedGridSchema({ table: { filter: [['status', '=', 'active']] } as any }).filter) - .toEqual([['status', '=', 'active']]); - }); - - it('no longer forwards a retired table.defaultSort — and does not rescue it into `sort` (objectui#5861)', () => { - // Flipped from `still forwards table.defaultSort`. Neither slot carries it: - // re-emitting the value into the canonical `sort` would be the tolerant - // alias the retirement exists to end, just moved one hop. - const grid = forwardedGridSchema({ table: { defaultSort: { field: 'name', order: 'asc' } } as any }); - expect(grid.defaultSort).toBeUndefined(); - expect(grid.sort).toBeUndefined(); - // CONTROL, same path: the canonical spelling still arrives. - expect(forwardedGridSchema({ table: { sort: [{ field: 'name', order: 'asc' }] } as any }).sort) - .toEqual([{ field: 'name', order: 'asc' }]); - }); - - it('leaves a canonical slot empty when only the legacy key is written', () => { - // The alias must not be re-emitted into the canonical slot: `ObjectGrid` - // keys `paginationEnabled` off `schema.pagination !== undefined`, so a - // synthesised `pagination` would turn pagination on for legacy authors. - const grid = forwardedGridSchema({ - table: { pageSize: 25, selectable: true, defaultFilters: { a: 1 }, defaultSort: { field: 'n', order: 'asc' } } as any, - }); - expect(grid.pagination).toBeUndefined(); - expect(grid.selection).toBeUndefined(); - expect(grid.sort).toBeUndefined(); - // objectui#12030 — the retired filter alias is not read, so nothing - // reaches the one filter slot the node carries either. - expect(grid.filter).toBeUndefined(); - expect(grid.defaultFilters).toBeUndefined(); - }); -}); - -describe('grid path: both spellings written', () => { - // ObjectView forwards both slots of the pagination and selection pairs and - // ObjectGrid arbitrates canonical-first (see the file header). The filter - // pair is resolved here instead (objectui#11880 item 5): the canonical value - // arrives in `filter` and no `defaultFilters` is written. - it('puts each canonical value in its slot; the paging aliases ride theirs, the filter alias is not written', () => { - const grid = forwardedGridSchema({ - table: { - pagination: { pageSize: 25 }, - pageSize: 10, - selection: { type: 'multiple' }, - selectable: false, - filter: [['stage', '=', 'won']], - defaultFilters: { stage: 'lost' }, - sort: [{ field: 'name', order: 'desc' }], - defaultSort: { field: 'created', order: 'asc' }, - } as any, - }); - expect(grid.pagination).toEqual({ pageSize: 25 }); - expect(grid.pageSize).toBe(10); - expect(grid.selection).toEqual({ type: 'multiple' }); - expect(grid.selectable).toBe(false); - expect(grid.filter).toEqual([['stage', '=', 'won']]); - expect(grid.defaultFilters).toBeUndefined(); - expect(grid.sort).toEqual([{ field: 'name', order: 'desc' }]); - // objectui#5861 — the sort pair has no legacy slot any more: the retired - // `defaultSort` is dropped, not forwarded beside the canonical value. - expect(grid.defaultSort).toBeUndefined(); - }); -}); - -describe('grid path: a named view still outranks the table segment', () => { - // The FILTER segments ahead of `table` (`listViews` entry, then `activeView`) - // ride `filter` since objectui#11880 item 5, the one filter slot the node - // carries, so the view's filter must be the value in it: a `table.filter` - // there would outrank the view the user is looking at. - // - // The SORT half moved in objectui#5270 — see the sort assertion below for - // why, and `ObjectView.namedViewSortArity.test.tsx` for the two consumers - // that made the old slot unreadable. What is pinned here either way is the - // PRECEDENCE, which the move preserves: the view still outranks `table`. - const namedView = { - listViews: { - won: { - label: 'Won', - filter: [['stage', '=', 'won']], - sort: [{ field: 'name', order: 'desc' }], - }, - }, - defaultListView: 'won', - }; - - it('keeps the named view filter in force over a table.filter', () => { - const grid = forwardedGridSchema({ - ...namedView, - table: { filter: [['stage', '=', 'lost']] } as any, - } as any); - expect(grid.filter).toEqual([['stage', '=', 'won']]); - expect(grid.defaultFilters).toBeUndefined(); - }); - - it('keeps the named view sort in force over a table.sort', () => { - // objectui#5102 pinned this as `sort: undefined` + - // `defaultSort: [{ field: 'name', order: 'desc' }]` and said in so many - // words that the pin should be UPDATED, not deleted, by whoever fixed the - // arity. Updated here: the array now rides the canonical slot — the only - // one of the two whose declared type (`SortConfig[]`) can hold more than - // one key — and the legacy slot carries the `table` segment alone. Precedence is what this block asserts and it is unchanged: - // ObjectGrid resolves `schemaSort ?? defaultSort`, so the named view wins - // over `table.sort` because it is what reaches the canonical slot. - const grid = forwardedGridSchema({ - ...namedView, - table: { sort: [{ field: 'created', order: 'asc' }] } as any, - } as any); - expect(grid.sort).toEqual([{ field: 'name', order: 'desc' }]); - expect(grid.defaultSort).toBeUndefined(); - }); - - it('keeps the named view sort in force, and a retired table.defaultSort beside it is not forwarded (objectui#5861)', () => { - // This used to assert the `table` segment rode the legacy slot beside the - // view's canonical one. The legacy slot is gone: the view sort still wins, - // and the retired key reaches ObjectGrid in neither slot. - const grid = forwardedGridSchema({ - ...namedView, - table: { defaultSort: { field: 'created', order: 'asc' } } as any, - } as any); - expect(grid.sort).toEqual([{ field: 'name', order: 'desc' }]); - expect(grid.defaultSort).toBeUndefined(); - }); -}); - -describe('non-grid path: ObjectView resolves the pair itself, canonical first', () => { - // Calendar / kanban / gallery / timeline fetch through ObjectView, so the - // precedence chosen for this card is directly observable on the wire here. - // - // ⚠️ objectui#4869 REWROTE the three `$orderby` expectations below, and the - // maintainer ruling of 2026-08-22 authorises exactly that. They used to pin - // the AUTHORED value reaching `$orderby` VERBATIM — which for the legacy - // `table.defaultSort` spelling was a live `400 INVALID_SORT`: a single - // `{ field, order }` object is read by the adapter as an `$orderby` MAP, so - // `Object.entries` folded it to the wire string `field,order`, two columns - // that do not exist, and the view came back EMPTY. This read site now lowers - // the whole chain through the shared sink (`convertSortToQueryParams`), so - // the assertions moved to the LOWERED value — the same value, in the one - // normalized shape every other object-bound block already sends. - // - // What objectui#5102 put here is NOT weakened by that: each expectation is - // still an exact `toEqual` on a fully specified map. What changed is the - // shape they arrive in, which is the defect objectui#4869 fixed. See - // `ObjectView.sortSink.test.tsx` for the read-site evidence. - // - // ⚠️ objectui#5861 then RETIRED the legacy half of the sort pair: the two - // cells below that pinned `table.defaultSort` as working now pin it inert — - // no `$orderby` from it alone, and no effect beside a canonical `table.sort`. - it('queries with a canonical table.filter', async () => { - const params = await queryParams(nonGridFind({ table: { filter: [['stage', '=', 'won']] } as any })); - expect(params.$filter).toEqual([['stage', '=', 'won']]); - }); - - it('lowers a ViewFilterRule[] table.filter through the shared sink', async () => { - // Same treatment the retired `table.defaultFilters` got — the canonical key - // reaches `$filter` as AST, not as bare rule objects (objectui#3431). - const params = await queryParams( - nonGridFind({ table: { filter: [{ field: 'stage', operator: 'eq', value: 'won' }] } as any }), - ); - expect(params.$filter).toEqual([['stage', 'equals', 'won']]); - }); - - it('queries with table.filter alone when a retired table.defaultFilters is written beside it', async () => { - const params = await queryParams( - nonGridFind({ - table: { filter: [['stage', '=', 'won']], defaultFilters: { stage: 'lost' } } as any, - }), - ); - expect(params.$filter).toEqual([['stage', '=', 'won']]); - }); - - it('a retired table.defaultFilters alone filters NOTHING (objectui#12030)', async () => { - // Flipped from `still queries with table.defaultFilters alone`, which - // asserted `['status', '=', 'active']`. The canonical control is `queries - // with a canonical table.filter` above, on this same path. - const params = await queryParams(nonGridFind({ table: { defaultFilters: { status: 'active' } } as any })); - expect(params.$filter).toBeUndefined(); - }); - - it('orders by a canonical table.sort', async () => { - const params = await queryParams(nonGridFind({ table: { sort: [{ field: 'name', order: 'desc' }] } as any })); - expect(params.$orderby).toEqual({ name: 'desc' }); - }); - - it('orders by table.sort alone when a retired table.defaultSort is written beside it', async () => { - const params = await queryParams( - nonGridFind({ - table: { sort: [{ field: 'name', order: 'desc' }], defaultSort: { field: 'created', order: 'asc' } } as any, - }), - ); - expect(params.$orderby).toEqual({ name: 'desc' }); - }); - - it('a retired table.defaultSort alone orders NOTHING (objectui#5861)', async () => { - // Flipped from `still orders by table.defaultSort alone`, which asserted - // `{ created: 'asc' }`. The key is an ADR-0049 tombstone the protocol - // refuses by name, so this read site no longer lowers it: the query goes - // out unsorted. The canonical control is `orders by a canonical - // table.sort` above, on this same path. - const params = await queryParams(nonGridFind({ table: { defaultSort: { field: 'created', order: 'asc' } } as any })); - expect(params.$orderby).toBeUndefined(); - }); -}); - -describe('delegated renderListView: canonical first, filter and sort aliases retired', () => { - it('hands over a canonical table.filter', () => { - expect(delegatedSchema({ table: { filter: [['stage', '=', 'won']] } as any }).filter) - .toEqual([['stage', '=', 'won']]); - }); - - it('hands over table.filter alone when a retired table.defaultFilters is written beside it', () => { - const s = delegatedSchema({ - table: { filter: [['stage', '=', 'won']], defaultFilters: { stage: 'lost' } } as any, - }); - expect(s.filter).toEqual([['stage', '=', 'won']]); - }); - - it('a retired table.defaultFilters alone hands over no filter (objectui#12030)', () => { - // Flipped from `still hands over table.defaultFilters alone`. The canonical - // control is `hands over a canonical table.filter` above. - expect(delegatedSchema({ table: { defaultFilters: { status: 'active' } } as any }).filter) - .toBeUndefined(); - }); - - it('hands over a canonical table.sort, and a retired table.defaultSort beside it changes nothing', () => { - const SORT = [{ field: 'name', order: 'desc' }]; - expect(delegatedSchema({ table: { sort: SORT } as any }).sort).toEqual(SORT); - const s = delegatedSchema({ - table: { sort: SORT, defaultSort: { field: 'created', order: 'asc' } } as any, - }); - expect(s.sort).toEqual(SORT); - }); - - it('hands over NO sort for a retired table.defaultSort alone (objectui#5861)', () => { - // Flipped from `still hands over table.defaultSort alone — WRAPPED`, which - // asserted `[{ field: 'created', order: 'asc' }]` (objectui#6235's wrap of - // the legacy single entry into the `SortConfig[]` the slot declares). The - // key is retired, so there is nothing to wrap: the delegated node gets no - // `sort`, exactly as if none had been authored. Canonical control: the - // cell above, on this same path. - expect(delegatedSchema({ table: { defaultSort: { field: 'created', order: 'asc' } } as any }).sort) - .toBeUndefined(); - }); -}); diff --git a/ui/packages/plugin-view/src/__tests__/ObjectView.createGrant-12082.test.tsx b/ui/packages/plugin-view/src/__tests__/ObjectView.createGrant-12082.test.tsx deleted file mode 100644 index 02237b93e51..00000000000 --- a/ui/packages/plugin-view/src/__tests__/ObjectView.createGrant-12082.test.tsx +++ /dev/null @@ -1,122 +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#12082 — the `object-view` toolbar's create button reads the - * `listNew` row of the affordance-to-grant map. - * - * Before, the button read the node's own `showCreate` and `operations.create` - * toggles and no grant at all, so a caller without the create grant was - * offered a New that opened a form the server then refused to save. The - * console's own list pages already read `listNew` (`ObjectView` / - * `ObjectDataPage` in `@object-ui/app-shell`); this is the same affordance on - * the SDUI `object-view` node, so it reads the same row. - * - * Measured through the REAL `MePermissionsProvider`, over the four grant - * shapes the map's enumeration pin uses. The authoring toggles still narrow: - * `showCreate: false` hides the button under the full grant. - */ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { render, screen, cleanup } from '@testing-library/react'; -import { MePermissionsProvider, type MePermissionsAuthenticatedResponse } from '@object-ui/permissions'; -import type { ObjectViewSchema, DataSource } from '@object-ui/types'; - -vi.mock('@object-ui/react', async (importOriginal) => { - const React = await import('react'); - return { - ...(await importOriginal>()), - SchemaRenderer: ({ schema }: any) =>
{schema?.type}
, - SchemaRendererContext: React.createContext(null), - subscribeDataChanges: () => () => {}, - notifyDataChanged: () => {}, - }; -}); -vi.mock('@object-ui/plugin-grid', async (importOriginal) => ({ - ...(await importOriginal>()), - ObjectGrid: () =>
, -})); -vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ - ...(await importOriginal>()), - ObjectForm: () =>
, -})); - -import { ObjectView } from '../ObjectView'; - -afterEach(cleanup); - -const OBJECT = 'contacts'; - -const dataSource = (): DataSource => ({ - find: vi.fn().mockResolvedValue([]), - findOne: vi.fn().mockResolvedValue(null), - create: vi.fn().mockResolvedValue({}), - update: vi.fn().mockResolvedValue({}), - delete: vi.fn().mockResolvedValue({}), - getObjectSchema: vi.fn().mockResolvedValue({ name: OBJECT, label: 'Contacts', fields: { name: { type: 'text' } } }), -} as unknown as DataSource); - -const SHAPES: Array<{ shape: string; bits: Record; create: boolean }> = [ - { shape: 'create-only', bits: { allowCreate: true, allowRead: true, allowEdit: false, allowDelete: false }, create: true }, - { shape: 'edit-only', bits: { allowCreate: false, allowRead: true, allowEdit: true, allowDelete: false }, create: false }, - { shape: 'read-only', bits: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, create: false }, - { shape: 'full', bits: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true }, create: true }, -]; - -const envelope = (bits: Record): MePermissionsAuthenticatedResponse => ({ - authenticated: true, - userId: 'u-pin', - tenantId: null, - hasAnyQueue: false, - positions: ['member'], - permissionSets: ['member'], - objects: { [OBJECT]: bits }, - fields: {}, -}); - -const schema = (extra: Partial = {}): ObjectViewSchema => ({ type: 'object-view', objectName: OBJECT, ...extra }); - -describe('object-view create button reads the listNew row (objectui#12082)', () => { - it.each(SHAPES)('$shape grant: New shows exactly when the create grant allows it', async ({ bits, create }) => { - render( - - - , - ); - // Wait for the schema read to settle so the policy layer has its answer. - await screen.findByTestId('object-grid'); - expect(screen.queryByRole('button', { name: 'New' }) !== null).toBe(create); - }); - - it('fail-open: with no permission provider mounted, New shows as before', async () => { - render(); - await screen.findByTestId('object-grid'); - expect(screen.getByRole('button', { name: 'New' })).toBeDefined(); - }); - - it('the authoring toggle still narrows: showCreate false hides New under the full grant', async () => { - render( - - - , - ); - await screen.findByTestId('object-grid'); - expect(screen.queryByRole('button', { name: 'New' })).toBeNull(); - }); - - it('the effective operation set still narrows: an apiOperations set without create hides New under the full grant', async () => { - render( - - - , - ); - await screen.findByTestId('object-grid'); - expect(screen.queryByRole('button', { name: 'New' })).toBeNull(); - }); -}); diff --git a/ui/packages/plugin-view/src/__tests__/ObjectView.formTitleI18n.test.tsx b/ui/packages/plugin-view/src/__tests__/ObjectView.formTitleI18n.test.tsx index b84e4265a5e..7cfa485ddec 100644 --- a/ui/packages/plugin-view/src/__tests__/ObjectView.formTitleI18n.test.tsx +++ b/ui/packages/plugin-view/src/__tests__/ObjectView.formTitleI18n.test.tsx @@ -51,12 +51,13 @@ * wrote a title, so the author's title is used — that branch is deliberately * untouched and this pins it. * - * The provider-less fallback is asserted in - * `ObjectView.formTitleNoProviderFallback.test.tsx` — it cannot live in this - * file, because `createI18n` registers its instance as react-i18next's - * module-global default and that registration survives `cleanup()`; a - * "no provider" render here would silently resolve against whichever locale a - * previous test mounted. + * The provider-less fallback is asserted in the + * `ObjectView.formTitleNoProviderFallback` block of + * `ObjectView.harness.test.tsx`. It was written outside this file because + * `createI18n` registers its instance as react-i18next's module-global default + * and that registration survives `cleanup()`; a "no provider" render here would + * have resolved against whichever locale a previous test mounted, until + * `vitest.setup.i18n-global.ts` began restoring the global after every test. */ import { describe, it, expect, vi, afterEach } from 'vitest'; diff --git a/ui/packages/plugin-view/src/__tests__/ObjectView.formTitleNoProviderFallback.test.tsx b/ui/packages/plugin-view/src/__tests__/ObjectView.formTitleNoProviderFallback.test.tsx deleted file mode 100644 index 3c0d236478c..00000000000 --- a/ui/packages/plugin-view/src/__tests__/ObjectView.formTitleNoProviderFallback.test.tsx +++ /dev/null @@ -1,156 +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. - */ - -/** - * `ObjectView`'s create / edit / view form title still resolves to ENGLISH, and - * to the SAME BYTES as before, when no `I18nProvider` is mounted — - * objectui#3462. - * - * This is not a nice-to-have. Routing a literal through `t()` without a working - * default is exactly how a provider-less consumer breaks, and it breaks in a - * suite that is not this one: `object-view` renders wherever its type is - * registered, so any host that renders schema without mounting a provider (this - * package's own tests, the preview gallery, an embedding app) reads whatever the - * defaults map says. The English defaults live in `VIEW_DEFAULT_TRANSLATIONS` - * (`ObjectView.tsx`) — that map is what `createSafeTranslation` falls back to - * when its `detail.recordDetailWithLabel` probe comes back unresolved, and it is - * what interpolates `{{object}}`. - * - * The byte-identity matters beyond aesthetics: the heading is `View Contacts` - * before the change and `View Contacts` after, so e2e specs and host tests that - * address this chrome by its English name keep addressing it. - * - * Direction: this file was GREEN before the change and is GREEN after. It pins - * the FALLBACK, not the fix — the fix is asserted in - * `ObjectView.formTitleI18n.test.tsx`. A missing map entry would turn it red by - * rendering the raw key `form.viewTitle`, which is precisely the regression it - * exists to catch. - * - * ── 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 the moment any test in a - * file mounts ``, every later - * "no provider" render in that same file silently resolves against the Chinese - * instance — 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 { describe, it, expect, vi, afterEach } from 'vitest'; -import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; -import '@testing-library/jest-dom'; -import type { DataSource } from '@object-ui/types'; -import { ObjectView } from '../ObjectView'; - -vi.mock('@object-ui/react', async (importOriginal) => { - const React = await import('react'); - return { - ...(await importOriginal>()), - SchemaRenderer: ({ schema }: any) => ( -
- {schema?.type} -
- ), - SchemaRendererContext: React.createContext(null), - subscribeDataChanges: () => () => {}, - notifyDataChanged: () => {}, - }; -}); - -vi.mock('@object-ui/plugin-grid', async (importOriginal) => ({ - ...(await importOriginal>()), - ObjectGrid: ({ schema, onRowClick, onEdit }: any) => ( -
- - -
- ), -})); - -vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ - ...(await importOriginal>()), - ObjectForm: ({ schema }: any) => ( -
- Form ({schema?.mode}) -
- ), -})); - -function dataSourceLabelled(label?: string): DataSource { - return { - find: vi.fn().mockResolvedValue({ data: [{ id: '1', name: 'Test' }], total: 1 }), - findOne: vi.fn().mockResolvedValue({ id: '1', name: 'Test' }), - create: vi.fn().mockResolvedValue({}), - update: vi.fn().mockResolvedValue({}), - delete: vi.fn().mockResolvedValue({}), - getObjectSchema: vi.fn().mockResolvedValue({ - name: 'contacts', - ...(label ? { label } : {}), - fields: { name: { label: 'Name', type: 'text' } }, - }), - } as unknown as DataSource; -} - -function renderView(label?: string) { - return render( - , - ); -} - -const openView = () => fireEvent.click(screen.getByTestId('grid-row')); -const openEdit = () => fireEvent.click(screen.getByTestId('grid-edit')); -const openCreate = () => fireEvent.click(screen.getByRole('button', { name: 'New' })); - -afterEach(() => cleanup()); - -describe('ObjectView form title — English fallback with no provider (objectui#3462)', () => { - it('heads a view-mode drawer "View {label}", never the raw key', async () => { - renderView('Contacts'); - openView(); - - await waitFor(() => expect(screen.getByText('View Contacts')).toBeInTheDocument()); - expect(screen.queryByText('form.viewTitle')).toBeNull(); - }); - - it('heads an edit-mode drawer "Edit {label}", never the raw key', async () => { - renderView('Contacts'); - openEdit(); - - await waitFor(() => expect(screen.getByText('Edit Contacts')).toBeInTheDocument()); - expect(screen.queryByText('form.editTitle')).toBeNull(); - }); - - it('heads a create-mode drawer "Create {label}", never the raw key', async () => { - renderView('Contacts'); - openCreate(); - - await waitFor(() => expect(screen.getByText('Create Contacts')).toBeInTheDocument()); - expect(screen.queryByText('form.createTitle')).toBeNull(); - }); - - it('falls back to the objectName when the object declares no label', async () => { - renderView(); - openView(); - - await waitFor(() => expect(screen.getByText('View contacts')).toBeInTheDocument()); - }); -}); diff --git a/ui/packages/plugin-view/src/__tests__/ObjectView.ganttBinding-7070.test.tsx b/ui/packages/plugin-view/src/__tests__/ObjectView.ganttBinding-7070.test.tsx index 4f0f695ca1f..0f1e5107e3f 100644 --- a/ui/packages/plugin-view/src/__tests__/ObjectView.ganttBinding-7070.test.tsx +++ b/ui/packages/plugin-view/src/__tests__/ObjectView.ganttBinding-7070.test.tsx @@ -11,8 +11,9 @@ * `startDateField` / `endDateField` at `'start_date'` / `'end_date'` for every * view, declared or not, exactly as the app-shell and ListView faces did. * - * The sibling of `ObjectView.calendarBinding-7029` next door. Fixing two of the - * three faces would leave this one fabricating the same names one route over — + * The sibling of the `ObjectView.calendarBinding-7029` block in + * `ObjectView.harness.test.tsx` next door. Fixing two of the three faces would + * leave this one fabricating the same names one route over — * which is the shape of the original defect, where objectui#3129 fixed the * timeline axis at app-shell alone and the two plugin faces kept inventing. * diff --git a/ui/packages/plugin-view/src/__tests__/ObjectView.harness.test.tsx b/ui/packages/plugin-view/src/__tests__/ObjectView.harness.test.tsx index 57b02bc5161..878a73e6c8e 100644 --- a/ui/packages/plugin-view/src/__tests__/ObjectView.harness.test.tsx +++ b/ui/packages/plugin-view/src/__tests__/ObjectView.harness.test.tsx @@ -53,6 +53,7 @@ import '@testing-library/jest-dom'; import { ListViewSchema as SpecListViewSchema } from '@objectstack/spec/ui'; import { ComponentRegistry, normalizeListViewSchema, convertSortToQueryParams } from '@object-ui/core'; import { I18nProvider } from '@object-ui/i18n'; +import { MePermissionsProvider, type MePermissionsAuthenticatedResponse } from '@object-ui/permissions'; import type { DataSource, DataSourceMutationEvent, ObjectViewSchema } from '@object-ui/types'; import { safeValidateSchema } from '@object-ui/types/zod'; import { ObjectView } from '../ObjectView'; @@ -1270,3 +1271,1363 @@ describe('ObjectView.filterSources', () => { }); }); }); + +// ── ObjectView.tableColumnsForwarding ────────────────────────────────────── + +/** + * objectui#5269 — `table.columns` reaches the NON-grid paths too. + * + * `ObjectViewSchema.table` is `Partial< Omit< ObjectGridSchema, 'type' | + * 'objectName' > >`, where `columns` is the canonical spelling and `fields` + * carries "@deprecated Use columns instead". `ObjectView` does not spread + * `table`; it forwards a hand-written whitelist, and that whitelist had ONE + * read of `table.columns` in the whole file — the grid one. Both other field- + * list read points took the deprecated key alone: + * + * generateViewSchema `baseProps` `table.fields` only + * delegated `renderListView` schema `table.fields` only + * + * So `table: { columns: [...] }` — the shape the type recommends — produced an + * empty field list off the grid path, from a schema that compiled and read + * correctly. Same silent-success shape as objectui#5102; different mechanism: + * not a whitelist that knows only legacy spellings, but one that disagreed + * with itself between two rendering paths. + * + * ## What this file pins, and on which surface + * + * Both changed sites are pinned where a CONSUMER can be observed, not merely + * where the value is emitted: + * + * `baseProps` -> `object-kanban` consumed as `cardFields` + * (`ObjectKanban` reads `schema.cardFields`) + * `baseProps` -> `object-tree` consumed as `fields` + * (`ObjectTree.getTreeConfig` reads `schema.fields`) + * delegated -> `list-view` consumed as `columns` (`ListView`'s column set) + * + * ## The surfaces that get NO test here, deliberately + * + * `baseProps` also feeds `object-gallery`, `object-calendar`, `object-timeline`, + * `object-gantt` and `object-map`. Measured on this tree, none of those five + * renderers reads a field list off its schema at all — every `.fields` in them + * is `objectDef.fields` (object METADATA) or gallery's `schema.grouping.fields`. + * So the value this fix forwards is inert on those five, before the fix and + * after it. Asserting `fields` on their generated schema would pin the emit + * and prove nothing about the surface, so it is stated here instead of tested. + * (The card objectui#5269 names kanban / gallery / calendar as the affected + * surfaces; the measurable set is kanban / tree / delegated.) + * + * ## One asymmetry between the two changed sites + * + * `table.columns` is `string[] | ListColumn[]`. The delegated `list-view` + * slot declares that same union and `ListView` reads both, so it takes the + * value RAW — flattening there would throw away the width/label the author + * wrote. The non-grid `baseProps` slot is a names slot (both segments ahead of + * `table` declare `string[]`; `ObjectKanban` indexes records by each entry), so + * the `ListColumn[]` half is resolved to names with `columnIdentity` — the + * same fold `ObjectGrid` applies to this very value. Both halves are pinned. + * + * ## Precedence + * + * Unchanged, and pinned below: the two segments ahead of `table` + * (`listViews` entry, then `activeView`) still outrank it. Within the `table` + * segment the canonical key wins and the deprecated one keeps working — the + * same rule objectui#5102 applied to its four pairs. + */ +describe('ObjectView.tableColumnsForwarding', () => { + restoreAfterBlock(); + + beforeEach(() => { + standIns.schemaRenderer = ({ schema }: any) => { + rendered.push(schema); + return
{schema?.type}
; + }; + standIns.objectGrid = ({ schema }: any) => { + gridSchemas.push(schema); + return
; + }; + standIns.objectForm = () =>
; + }); + + /** Every schema the view hands to SchemaRenderer, in order. */ + const rendered: any[] = []; + + /** The grid the view delegates to, replaced by a probe that records its schema. */ + const gridSchemas: any[] = []; + + const mockDataSource = () => ({ + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ name: 'task', fields: {} }), + }); + + /** + * Render a non-grid view and return the schema `generateViewSchema` produced. + * `views` drives the view types `defaultViewType` cannot spell (`tree`). + */ + async function generatedSchema( + schema: Partial, + views?: any[], + ): Promise { + rendered.length = 0; + render( + , + ); + await waitFor(() => expect(rendered.length).toBeGreaterThan(0)); + return rendered[rendered.length - 1]; + } + + /** Render through the delegated `renderListView` slot and return its schema. */ + function delegatedSchema(schema: Partial): any { + const seen: any[] = []; + render( + { seen.push(s); return
; }} + />, + ); + return seen[0]; + } + + /** Render on the grid path and return the schema ObjectGrid was handed. */ + function forwardedGridSchema(schema: Partial): any { + gridSchemas.length = 0; + render( + , + ); + return gridSchemas[0]; + } + + const KANBAN = { defaultViewType: 'kanban' as const }; + const TREE_VIEWS = [{ id: 'tree-1', label: 'Tree', type: 'tree' as const }]; + + beforeEach(() => { + vi.clearAllMocks(); + rendered.length = 0; + gridSchemas.length = 0; + }); + + describe('kanban: a canonical table.columns reaches the cards (objectui#5269)', () => { + it('forwards table.columns as the kanban card fields', async () => { + // Before the fix this arrived as `cardFields: []` — the card fields the + // author declared were dropped at the forwarding hop, not at the renderer. + const s = await generatedSchema({ ...KANBAN, table: { columns: ['stage', 'owner'] } as any }); + expect(s.type).toBe('object-kanban'); + expect(s.cardFields).toEqual(['stage', 'owner']); + expect(s.fields).toEqual(['stage', 'owner']); + }); + + it('still forwards the deprecated table.fields', async () => { + // The alias must keep working — every view authored against today's docs + // writes it. (Green before AND after the fix: this pins the half that must + // NOT change, not the defect.) + const s = await generatedSchema({ ...KANBAN, table: { fields: ['stage'] } as any }); + expect(s.cardFields).toEqual(['stage']); + }); + + it('prefers the canonical table.columns over the deprecated table.fields', async () => { + const s = await generatedSchema({ + ...KANBAN, + table: { columns: ['stage'], fields: ['owner'] } as any, + }); + expect(s.cardFields).toEqual(['stage']); + }); + + it('resolves the ListColumn[] form of table.columns to field NAMES', async () => { + // `ObjectGridSchema.columns` is `string[] | ListColumn[]`, but this slot is + // a names slot: `resolveKanbanCardFields` casts to `string[]` and the card + // loop indexes the record by each entry. Forwarding the objects raw would + // produce a non-empty card field list naming nothing — and a non-empty list + // suppresses ObjectKanban's `highlightFields` fallback, so the cards would + // come out emptier than before this card. Resolved at the boundary with + // `columnIdentity`, the same fold ObjectGrid applies to this very value. + const s = await generatedSchema({ + ...KANBAN, + table: { columns: [{ field: 'stage', label: 'Stage', width: 120 }] } as any, + }); + expect(s.cardFields).toEqual(['stage']); + }); + + it('falls through to table.fields when no column entry names a field', async () => { + // The empty-array trap: `[] || x` is `[]`, so a `columns` whose entries all + // fail to resolve must yield `undefined`, not an empty list that stops the + // chain on a falsy-looking truthy value. + const s = await generatedSchema({ + ...KANBAN, + table: { columns: [{ label: 'no identity here' }], fields: ['owner'] } as any, + }); + expect(s.cardFields).toEqual(['owner']); + }); + + it('keeps a named view outranking the table segment', async () => { + // Precedence is unchanged by this card and must stay that way: the two + // segments ahead of `table` still win. (Green on both legs — a guard on + // the fix, not evidence of it.) + const s = await generatedSchema({ + ...KANBAN, + listViews: { mine: { label: 'Mine', columns: ['assignee'] } }, + defaultListView: 'mine', + table: { columns: ['stage'] } as any, + } as any); + expect(s.cardFields).toEqual(['assignee']); + }); + }); + + describe('tree: a canonical table.columns reaches the flat columns (objectui#5269)', () => { + it('forwards table.columns as the tree view fields', async () => { + const s = await generatedSchema({ table: { columns: ['stage', 'owner'] } as any }, TREE_VIEWS); + expect(s.type).toBe('object-tree'); + expect(s.fields).toEqual(['stage', 'owner']); + }); + + it('still forwards the deprecated table.fields', async () => { + const s = await generatedSchema({ table: { fields: ['stage'] } as any }, TREE_VIEWS); + expect(s.fields).toEqual(['stage']); + }); + }); + + describe('delegated renderListView: canonical first, alias still working (objectui#5269)', () => { + it('hands over a canonical table.columns as the list-view columns', () => { + expect(delegatedSchema({ table: { columns: ['stage', 'owner'] } as any }).columns) + .toEqual(['stage', 'owner']); + }); + + it('hands over the ListColumn[] form of table.columns unchanged', () => { + // `ObjectGridSchema.columns` (and `list-view`'s own `columns`) are both + // `string[] | ListColumn[]`, so the object form is a declared shape on + // this slot and must not be flattened on the way through. + const cols = [{ field: 'stage', label: 'Stage', width: 200 }]; + expect(delegatedSchema({ table: { columns: cols } as any }).columns).toEqual(cols); + }); + + it('still hands over the deprecated table.fields', () => { + expect(delegatedSchema({ table: { fields: ['stage'] } as any }).columns).toEqual(['stage']); + }); + + it('prefers the canonical table.columns over the deprecated table.fields', () => { + expect(delegatedSchema({ table: { columns: ['stage'], fields: ['owner'] } as any }).columns) + .toEqual(['stage']); + }); + + it('keeps a named view outranking the table segment', () => { + const s = delegatedSchema({ + listViews: { mine: { label: 'Mine', columns: ['assignee'] } }, + defaultListView: 'mine', + table: { columns: ['stage'] } as any, + } as any); + expect(s.columns).toEqual(['assignee']); + }); + }); + + describe('grid path: unchanged by this card — the control', () => { + it('keeps emitting table.columns and table.fields in their own slots', () => { + // The grid path forwards BOTH slots and lets `ObjectGrid` arbitrate + // (objectui#5102's shape). This card does not touch it; the assertion is + // here so a future "make all three sites identical" tidy-up cannot + // collapse the two slots without a red test. + // + // The `undefined` half is counter-probed by the populated half in the same + // render: `fields: undefined` alone would also be what a probe that saw no + // grid schema at all would report. + const canonicalOnly = forwardedGridSchema({ table: { columns: ['stage'] } as any }); + expect(canonicalOnly.columns).toEqual(['stage']); + expect(canonicalOnly.fields).toBeUndefined(); + + const legacyOnly = forwardedGridSchema({ table: { fields: ['owner'] } as any }); + expect(legacyOnly.fields).toEqual(['owner']); + expect(legacyOnly.columns).toBeUndefined(); + }); + }); +}); + +// ── ObjectView.strayGroupByLane-9242 ─────────────────────────────────────── + +/** + * objectui#9242 — a stray `groupBy` in `viewOptions.kanban` OVERRODE the lane + * `generateViewSchema` had just resolved. THE SECOND ROUTE. + * + * `generateViewSchema`'s kanban branch destructured + * `columns`/`groupByField`/`groupField`/`titleField`/`conditionalFormatting` + * out of `viewOptions.kanban` and spread the REST *after* its own `groupBy`. So + * an authored `kanban.groupBy` survived into `restKanban` and won over the + * canonical `groupByField` the branch had already resolved — the board grouped + * by the stray key, and nothing said so. + * + * ## WHY THIS IS A SECOND ROUTE, NOT A DUPLICATE OF objectui#8365 + * + * `generateViewSchema` runs precisely when no host supplied `renderListView` — + * the authored `object-view` element — so it never passes through `ListView`, + * and PR objectui#9236's destructure fix does not reach it. This is the same + * distinction the `calendar` branch a few lines below records for objectui#7029. + * + * ## THE RULING — already made; this card only applies it to the second route + * + * Maintainer ruling of 2026-09-12 (decision batch #117 item 5, verbatim + * 「8365 同意」) — option B: the CANONICAL spelling wins and the stray key is + * refused BY NAME. Option A (strip the key and re-group in silence) was NOT + * taken. ⛔ This is not an open question; see objectui#8365 / PR objectui#9236. + * + * ## THE DISTINGUISHING FIXTURE, and why one lane name would not do + * + * The two spellings carry two DIFFERENT lane names. With a single name the + * override arm cannot fail BY CONSTRUCTION — which is exactly how the twin + * defect stayed invisible for so long on the first route: the one producer that + * fed it wrote both spellings with the same value. + * + * kanban = { groupBy: 'LANE_FROM_STRAY_GROUPBY', + * groupByField: 'LANE_FROM_CANONICAL' } + * + * ## ⭐ THE ARM THAT IS **MEASURED HERE**, NOT COPIED FROM THE TWIN + * + * `ListView` floors its lane on `detectStatusField(objectDef)`. THIS branch + * floors it on the LITERAL `'status'` (`groupByField || groupField || 'status'`), + * so the "stray key alone" control answers differently on the two routes and + * copying the twin's assertion would have pinned the wrong thing. + * + * Measured on this tree before this file was written (base `96919a459`, every + * lit control firing), `node.groupBy` per fixture: + * + * | kanban config | BEFORE fix | AFTER fix | + * | -------------------------------------- | ---------- | ---------- | + * | `{ groupBy: S, groupByField: C }` | **S** ⛔ | C | + * | `{ groupBy: S }` | **S** ⛔ | `'status'` | + * | `{ groupBy: S }`, object has NO status | **S** ⛔ | `'status'` | + * | `{}` | `'status'` | `'status'` | + * | `{ groupByField: C }` | C | C | + * | `{ groupField: C }` | C | C | + * + * The third row is the one that tells a LITERAL floor apart from a detector: an + * object declaring no `status` field still floors at `'status'` here, where the + * twin would have produced `undefined`. Pinned below for exactly that reason. + * + * ## WHAT THIS CARD DELIBERATELY DOES NOT TOUCH — four faces + * + * 1. THE CONTRACT HALF — AND WHAT IT ACTUALLY REACHES. `KanbanConfig`'s + * `aliasKeyRefusal` for `groupBy` (`@object-ui/types`, `zod/objectql.zod.ts`) + * landed with PR objectui#9236, and is pinned in + * `packages/plugin-list/src/__tests__/ListView.strayGroupByRefused-8365.test.tsx` + * (runtime face) and + * `packages/types/src/__tests__/kanban-stray-group-by-refusal-8365.test.ts` + * (type face). ⛔ That arm alone does NOT cover both routes, and this note + * used to say it did (objectui#9655). This route's door is a second install + * site of the same string, added by objectui#10321: `ObjectViewSchema`'s + * named-view check refuses `listViews.KEY.kanban.groupBy` in both nestings, + * pinned in + * `packages/types/src/__tests__/named-view-kanban-stray-group-by-10321.test.ts`. + * + * ⭐ READ THE REACH, ⛔ DO NOT TRUST A COUNT WRITTEN DOWN HERE — a count in + * prose is derived once and re-derived never, and the one that stood here + * was wrong. Every place the arm reaches an authored document is a + * reference to the `KanbanStrayGroupByRefusal` symbol, so the instrument is + * an enumeration of that symbol: + * + * git grep -n KanbanStrayGroupByRefusal packages/types/src + * + * The declaration line is that run's own positive control — a run that does + * not print it found nothing at all, rather than finding no install sites. + * Read which object each hit hangs off; the arm's own "WHERE THIS ARM IS + * INSTALLED" note in that file states the routes and nestings it was + * written for. The first two pins above build `list-view` fixtures and the + * third `object-view` ones, which is the same reading taken from the other + * side. + * + * ⚠️ THE MODEL ERROR, NOT JUST THE SENTENCE. The claim that made the wrong + * one feel safe to write was the tail of this item: that what remains on + * this route is "a document that never went through a validator". ⛔ FALSE. + * `safeValidateSchema` DOES judge an `object-view` document — it ACCEPTED + * this one until objectui#10321, and refuses it now. The instrument is next + * door in this directory: + * `ObjectView.calendarAliasRefused-8355.test.tsx`'s "REFUSED at the read + * door" half drives a named view under `listViews` through + * `safeValidateSchema` and gets a refusal — same door, same document shape, + * a different key family. ⇒ write ACCEPTED, ⛔ never "not validated": + * "not refused" is the weaker and true claim, and the substitution of the + * stronger one got written down three times in this one neighbourhood — + * here; in `KanbanStrayGroupByRefusal`'s own PUBLISHED message, which an + * author still reads today (objectui#9653, ⛔ not this card's file); and in + * objectui#8355's calendar stem, where a contract review caught the clause + * before it landed (objectui#9648). + * + * ⛔ Still not re-pinned here: this file pins the BEHAVIOUR half, which + * holds for any document that reaches the branch carrying the key, whether + * or not it passed a validator. The door is pinned where it is declared (the + * `types` file above). Which value type `listViews` itself should enforce is + * objectui#7928's question, and neither this card nor objectui#10321 + * answers it. + * 2. THE LIVE LEGACY ALIAS `kanban.groupField`, a legacy spelling of the spec's + * `groupByField`. Still read, still resolves the lane — CONTROL below. + * 3. `groupBy` ON THE GENERATED NODE. That is the canonical lane key + * `ObjectKanban` actually reads (thirteen `schema.groupBy` reads in + * `ObjectKanban.tsx`); the stray key is a VIEW-CONFIG spelling. Do not + * confuse the two — the whole fix is to stop the second from reaching the + * first. + * 4. `ListView` — objectui#8365's face, closed. + * + * ## REVERSE VERIFICATION (the ablation leg), direction predicted before running + * + * Run in its STRONGEST form: this file was written and run BEFORE + * `ObjectView.tsx` was touched, so the "unmodified" arm is the REAL BASE TREE + * rather than an injected mutation — no on-disk mutation to prove, no restore + * leg to get wrong. Predicted and then observed on `96919a459`: the three + * override arms go RED naming `LANE_FROM_STRAY_GROUPBY`, while every CONTROL + * arm (canonical, legacy alias, floor, passthrough, sibling keys) stays GREEN. + * The asymmetry is what shows the controls are not carrying the override arms. + */ +describe('ObjectView.strayGroupByLane-9242', () => { + restoreAfterBlock(); + + beforeEach(() => { + standIns.schemaRenderer = ({ schema }: any) => { + rendered.push(schema); + return
{schema?.type}
; + }; + standIns.objectGrid = () =>
; + standIns.objectForm = () =>
; + }); + + /** The two lane names the fixture holds apart. */ + const STRAY = 'LANE_FROM_STRAY_GROUPBY'; + const CANONICAL = 'LANE_FROM_CANONICAL'; + /** The literal this branch floors the lane at — NOT a detector. */ + const FLOOR = 'status'; + + /** Every schema the view hands to SchemaRenderer, in order. */ + const rendered: any[] = []; + + /** An object that DOES declare a `status` field. */ + const WITH_STATUS = { status: { name: 'status', type: 'text', label: 'Status' } }; + /** An object that declares NO `status` field — tells a literal floor from a detector. */ + const WITHOUT_STATUS = { name: { name: 'name', type: 'text', label: 'Name' } }; + + const dataSource = (fields: Record = WITH_STATUS): any => ({ + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ name: 'task', fields }), + }); + + /** + * Renders a kanban view through the REGISTERED renderer's path — i.e. with no + * `renderListView` — and returns the `object-kanban` node the branch emits. + * + * ⚠️ `cleanup()` is load-bearing, not hygiene. Without it the previously + * mounted `ObjectView`s stay mounted and keep pushing into `rendered`, so + * `rendered[rendered.length - 1]` returns a STALE node and every reading comes + * back shifted by one fixture. That failure was hit while measuring this card: + * it renders as a plausible table in which the lit controls quietly answer the + * previous row's question. ⛔ Do not drop it. + */ + async function generatedKanbanNode( + kanban: Record, + fields?: Record, + ): Promise { + cleanup(); + rendered.length = 0; + render( + , + ); + await waitFor(() => expect(rendered.length).toBeGreaterThan(0)); + const node = rendered[rendered.length - 1]; + expect(node.type).toBe('object-kanban'); + return node; + } + + beforeEach(() => { + rendered.length = 0; + }); + + describe('objectui#9242 — the CANONICAL lane wins on the node `generateViewSchema` emits', () => { + it('the distinguishing fixture resolves the lane from `groupByField`, not the stray `groupBy`', async () => { + const node = await generatedKanbanNode({ groupBy: STRAY, groupByField: CANONICAL }); + // Before the fix this read `LANE_FROM_STRAY_GROUPBY`: the `...restKanban` + // spread landed after the branch's own `groupBy`. + expect(node.groupBy).toBe(CANONICAL); + expect(node.groupBy).not.toBe(STRAY); + }); + + it('the stray VALUE does not reach the generated node under any key at all', async () => { + const node = await generatedKanbanNode({ groupBy: STRAY, groupByField: CANONICAL }); + // The assertion above is about the lane key's VALUE. This one is about the + // stray value: it must appear nowhere on the node, including under some + // other key the spread might have carried it through. + expect(Object.values(node)).not.toContain(STRAY); + expect(Object.prototype.hasOwnProperty.call(node, 'groupByField')).toBe(false); + }); + + it('LANE CONTROL (measured here, ⛔ not copied from `ListView`): `groupBy` alone floors at the literal', async () => { + // ⭐ THE ARM THE CARD SINGLED OUT. `ListView` answers this with + // `detectStatusField(objectDef)`; this branch answers with the LITERAL + // `'status'`. Option B, not a silent re-grouping: the stray key stops + // steering the board, and what replaces it is this branch's own floor. + const node = await generatedKanbanNode({ groupBy: STRAY }); + expect(node.groupBy).toBe(FLOOR); + expect(node.groupBy).not.toBe(STRAY); + }); + + it('LANE CONTROL: the floor is a LITERAL — it holds when the object declares no `status` field', async () => { + // This is the row that tells a literal floor apart from a detector, and so + // the row that would redden first if anyone ever "aligned" this branch with + // the twin by swapping in `detectStatusField`. That would be a behaviour + // change on stored views and belongs on its own card, not in a tidy-up. + const node = await generatedKanbanNode({ groupBy: STRAY }, WITHOUT_STATUS); + expect(node.groupBy).toBe(FLOOR); + expect(node.groupBy).not.toBe(STRAY); + expect(node.groupBy).toBeDefined(); + }); + }); + + describe('objectui#9242 — the controls, each able to fire on its own', () => { + it('CONTROL: the declared `kanban.groupByField` still resolves the lane by itself', async () => { + const node = await generatedKanbanNode({ groupByField: CANONICAL }); + expect(node.groupBy).toBe(CANONICAL); + }); + + it('the retired legacy alias `kanban.groupField` names no lane: the floor answers (objectui#6152 round 14)', async () => { + // This card left the VIEW-LEVEL alias live (only the third spelling was + // stripped), and this arm pinned that it still resolved the lane. + // objectui#6152 round 14 retired the alias read on its own ruling, so a + // stored `groupField` now gets the same lane as a block with no key. + const node = await generatedKanbanNode({ groupField: CANONICAL }); + expect(node.groupBy).toBe(FLOOR); + expect(node.groupBy).not.toBe(CANONICAL); + }); + + it('CONTROL: with neither declared key the lane is the floor, exactly as before this card', async () => { + const node = await generatedKanbanNode({}); + expect(node.groupBy).toBe(FLOOR); + }); + + it('DECLARED-KEYS CONTROL: a declared sibling key reaches the node; an undeclared one does not (objectui#6152 round 14)', async () => { + // This card stripped exactly ONE more key and kept `restKanban` as the + // passthrough, and this arm pinned that an undeclared key still rode + // through. objectui#6152 round 14 retired the passthrough on purpose: the + // branch forwards the spec kanban block's keys by name, so the declared + // `summarizeField` still arrives and an undeclared key does not. + const node = await generatedKanbanNode({ groupByField: CANONICAL, summarizeField: 'amount', zzzBogusKey: 'rides-through' }); + expect(node.summarizeField).toBe('amount'); + expect(node).not.toHaveProperty('zzzBogusKey'); + expect(node.groupBy).toBe(CANONICAL); + }); + + it('CONTROL: the rest of the kanban branch still resolves — titleField and cardFields', async () => { + // Without this arm every assertion above would pass just as happily against + // a kanban branch that had stopped emitting anything at all. + const node = await generatedKanbanNode({ + groupByField: CANONICAL, + columns: ['name', 'amount'], + titleField: 'name', + }); + expect(node.titleField).toBe('name'); + expect(node.cardFields).toEqual(['name', 'amount']); + // `columns` on the node would be read as LANES by `object-kanban`, so the + // branch maps it to `cardFields` and strips it. Unchanged by this card. + expect(node.columns).toBeUndefined(); + }); + }); +}); + +// ── ObjectView.canonicalTableKeys ────────────────────────────────────────── + +/** + * objectui#5102 — the canonical `table` keys take effect. + * + * `ObjectViewSchema.table` is documented as "inherits from ObjectGridSchema", + * but `ObjectView` does not spread it: it forwards a hand-written whitelist of + * keys. That whitelist carried only the DEPRECATED half of four pairs, and + * dropped every canonical successor: + * + * forwarded (legacy) | canonical successor on ObjectGridSchema | forwarded? + * ---------------------|-----------------------------------------|----------- + * pageSize | pagination: PaginationConfig | NO + * selectable | selection: SelectionConfig | NO + * defaultFilters | filter | NO + * defaultSort | sort | NO + * + * ⚠️ objectui#5861 RETIRED the fourth pair's legacy half. `defaultSort` is an + * ADR-0049 tombstone — `@objectstack/spec` 17.3.0 refuses it by name on + * `object-grid` — and every reader dropped it together: ObjectView no longer + * forwards it on the grid path, no longer lowers it on the non-grid fetch or + * into the delegated `renderListView` sort, and ObjectGrid no longer reads it. + * The cells below that pinned it as a WORKING alias were flipped, not deleted: + * each now asserts the key is inert on its path, beside a canonical `sort` + * control on the same path. The other three pairs are untouched. + * + * ⚠️ objectui#12030 RETIRED the third pair's legacy half the same way. + * `@objectstack/spec` 18 tombstones `object-grid.defaultFilters` + * (objectstack#11509), refusing it by name, and `ObjectView` stopped reading + * `table.defaultFilters` on all three paths (the grid hand-off, the non-grid + * fetch, the delegated `renderListView` schema), as `ObjectGrid` stopped + * reading its own. The cells that pinned it as a working alias were flipped + * the objectui#5861 way: each asserts the key is inert on its path, beside a + * canonical `filter` control on the same path. + * + * So an author writing the shape the type recommends — `table: { pagination: + * { pageSize: 25 } }` — got a view that compiled, read correctly, and did + * NOTHING. There was no failure signal anywhere: the key is declared on + * `ObjectGridSchema`, `ObjectGrid` reads it, and only this forwarding hop lost + * it. That silent-success shape is the defect, not the missing feature. + * + * Maintainer ruling of 2026-08-18 (verbatim 「同意」): extend the whitelist so + * the canonical keys take effect, keeping the legacy spellings as WORKING + * aliases. Both halves are pinned here — a fix that quietly retired the legacy + * spelling would break every view authored against today's docs. + * + * PRECEDENCE, when an author writes both: the canonical key wins. Where + * `ObjectView` itself resolves the pair (the non-grid fetch, and the delegated + * `renderListView` schema) that choice is encoded in this file's `||` chains + * and pinned below. On the grid path `ObjectView` forwards BOTH slots of the + * pagination and selection pairs and `ObjectGrid` arbitrates canonical-first + * (`schema.pagination?.pageSize ?? schema.pageSize`; `if (schema.selection + * ?.type) … else if (schema.selectable !== undefined)`). Synthesising a + * `pagination` object out of a legacy `pageSize` would flip `ObjectGrid`'s + * `paginationEnabled` (`schema.pagination !== undefined ? true : …`) for every + * view that only ever wrote the legacy key. + * + * objectui#11880 item 5: the FILTER pair is the exception on the grid path. + * `ObjectView` resolves it itself and hands the winner in `filter` alone, + * never writing `defaultFilters`: `ObjectGrid`'s export and page reset read + * `filter` only, so a value in the legacy slot narrowed the rows but not the + * download. The precedence is unchanged (the canonical key wins unless it + * lowers to nothing), and `ObjectView.gridFilterHandoff-11880.test.tsx` reads + * it through the real grid. + */ +describe('ObjectView.canonicalTableKeys', () => { + restoreAfterBlock(); + + beforeEach(() => { + standIns.schemaRenderer = ({ schema }: any) =>
{schema?.type}
; + standIns.objectGrid = ({ schema }: any) => { + gridSchemas.push(schema); + return
; + }; + standIns.objectForm = () =>
; + }); + + /** + * The grid the view delegates to, replaced by a probe that records the schema + * it was handed. What reaches this object IS the forwarding whitelist. + */ + const gridSchemas: any[] = []; + + const mockDataSource = () => ({ + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ name: 'task', fields: {} }), + }); + + /** Render on the grid path and return the schema ObjectGrid was handed. */ + function forwardedGridSchema(schema: Partial): any { + gridSchemas.length = 0; + render( + , + ); + return gridSchemas[0]; + } + + /** Render on a NON-grid path (ObjectView fetches for itself) and return `find`. */ + function nonGridFind(schema: Partial) { + const ds = mockDataSource(); + render( + , + ); + return ds.find; + } + + async function queryParams(find: ReturnType) { + await waitFor(() => expect(find).toHaveBeenCalled()); + return find.mock.calls[0][1]; + } + + /** Render through the delegated `renderListView` slot and return its schema. */ + function delegatedSchema(schema: Partial): any { + const seen: any[] = []; + render( + { seen.push(s); return
; }} + />, + ); + return seen[0]; + } + + beforeEach(() => { + vi.clearAllMocks(); + gridSchemas.length = 0; + }); + + describe('grid path: the canonical table keys reach ObjectGrid', () => { + // Each of these four was `undefined` on the forwarded schema before + // objectui#5102 — the value the author wrote never left ObjectView. + it('forwards table.pagination', () => { + const grid = forwardedGridSchema({ table: { pagination: { pageSize: 25 } } as any }); + expect(grid.pagination).toEqual({ pageSize: 25 }); + }); + + it('forwards table.pagination.pageSizeOptions along with it', () => { + const grid = forwardedGridSchema({ + table: { pagination: { pageSize: 25, pageSizeOptions: [25, 50] } } as any, + }); + expect(grid.pagination).toEqual({ pageSize: 25, pageSizeOptions: [25, 50] }); + }); + + it('forwards table.selection', () => { + const grid = forwardedGridSchema({ table: { selection: { type: 'multiple' } } as any }); + expect(grid.selection).toEqual({ type: 'multiple' }); + }); + + it('forwards table.filter', () => { + const grid = forwardedGridSchema({ table: { filter: [['stage', '=', 'won']] } as any }); + expect(grid.filter).toEqual([['stage', '=', 'won']]); + }); + + it('forwards table.sort', () => { + const grid = forwardedGridSchema({ table: { sort: [{ field: 'name', order: 'desc' }] } as any }); + expect(grid.sort).toEqual([{ field: 'name', order: 'desc' }]); + }); + + it('forwards table.sort VERBATIM — even the retired string form (objectui#8221)', () => { + // `ObjectGridSchema.sort` is `SortConfig[]` since decision batch #77; the + // string arm is retired. This delegation is nonetheless VALUE-AGNOSTIC and + // stays that way on purpose: a stored metadata row still reaches it + // carrying the old spelling, and the place that judges the value is the + // shared sink downstream (`ObjectView.sortSink.test.tsx` pins the refusal + // and its diagnostic). Coercing or dropping it here would put a second + // opinion about the sort dialect in the delegation path — the fourth + // dialect this whole chain exists to avoid. + const grid = forwardedGridSchema({ table: { sort: 'name desc' } as any }); + expect(grid.sort).toBe('name desc'); + }); + }); + + describe('grid path: the legacy spellings still work', () => { + it('still forwards table.pageSize', () => { + expect(forwardedGridSchema({ table: { pageSize: 25 } as any }).pageSize).toBe(25); + }); + + it('still forwards table.selectable', () => { + expect(forwardedGridSchema({ table: { selectable: true } as any }).selectable).toBe(true); + }); + + it('no longer reads a retired table.defaultFilters — and does not rescue it into `filter` (objectui#12030)', () => { + // Flipped from `still honours table.defaultFilters, handed over in + // `filter`. Neither slot carries it. + const grid = forwardedGridSchema({ table: { defaultFilters: { status: 'active' } } as any }); + expect(grid.filter).toBeUndefined(); + expect(grid.defaultFilters).toBeUndefined(); + // CONTROL, same path: the canonical spelling still arrives. + expect(forwardedGridSchema({ table: { filter: [['status', '=', 'active']] } as any }).filter) + .toEqual([['status', '=', 'active']]); + }); + + it('no longer forwards a retired table.defaultSort — and does not rescue it into `sort` (objectui#5861)', () => { + // Flipped from `still forwards table.defaultSort`. Neither slot carries it: + // re-emitting the value into the canonical `sort` would be the tolerant + // alias the retirement exists to end, just moved one hop. + const grid = forwardedGridSchema({ table: { defaultSort: { field: 'name', order: 'asc' } } as any }); + expect(grid.defaultSort).toBeUndefined(); + expect(grid.sort).toBeUndefined(); + // CONTROL, same path: the canonical spelling still arrives. + expect(forwardedGridSchema({ table: { sort: [{ field: 'name', order: 'asc' }] } as any }).sort) + .toEqual([{ field: 'name', order: 'asc' }]); + }); + + it('leaves a canonical slot empty when only the legacy key is written', () => { + // The alias must not be re-emitted into the canonical slot: `ObjectGrid` + // keys `paginationEnabled` off `schema.pagination !== undefined`, so a + // synthesised `pagination` would turn pagination on for legacy authors. + const grid = forwardedGridSchema({ + table: { pageSize: 25, selectable: true, defaultFilters: { a: 1 }, defaultSort: { field: 'n', order: 'asc' } } as any, + }); + expect(grid.pagination).toBeUndefined(); + expect(grid.selection).toBeUndefined(); + expect(grid.sort).toBeUndefined(); + // objectui#12030 — the retired filter alias is not read, so nothing + // reaches the one filter slot the node carries either. + expect(grid.filter).toBeUndefined(); + expect(grid.defaultFilters).toBeUndefined(); + }); + }); + + describe('grid path: both spellings written', () => { + // ObjectView forwards both slots of the pagination and selection pairs and + // ObjectGrid arbitrates canonical-first (see the file header). The filter + // pair is resolved here instead (objectui#11880 item 5): the canonical value + // arrives in `filter` and no `defaultFilters` is written. + it('puts each canonical value in its slot; the paging aliases ride theirs, the filter alias is not written', () => { + const grid = forwardedGridSchema({ + table: { + pagination: { pageSize: 25 }, + pageSize: 10, + selection: { type: 'multiple' }, + selectable: false, + filter: [['stage', '=', 'won']], + defaultFilters: { stage: 'lost' }, + sort: [{ field: 'name', order: 'desc' }], + defaultSort: { field: 'created', order: 'asc' }, + } as any, + }); + expect(grid.pagination).toEqual({ pageSize: 25 }); + expect(grid.pageSize).toBe(10); + expect(grid.selection).toEqual({ type: 'multiple' }); + expect(grid.selectable).toBe(false); + expect(grid.filter).toEqual([['stage', '=', 'won']]); + expect(grid.defaultFilters).toBeUndefined(); + expect(grid.sort).toEqual([{ field: 'name', order: 'desc' }]); + // objectui#5861 — the sort pair has no legacy slot any more: the retired + // `defaultSort` is dropped, not forwarded beside the canonical value. + expect(grid.defaultSort).toBeUndefined(); + }); + }); + + describe('grid path: a named view still outranks the table segment', () => { + // The FILTER segments ahead of `table` (`listViews` entry, then `activeView`) + // ride `filter` since objectui#11880 item 5, the one filter slot the node + // carries, so the view's filter must be the value in it: a `table.filter` + // there would outrank the view the user is looking at. + // + // The SORT half moved in objectui#5270 — see the sort assertion below for + // why, and `ObjectView.namedViewSortArity.test.tsx` for the two consumers + // that made the old slot unreadable. What is pinned here either way is the + // PRECEDENCE, which the move preserves: the view still outranks `table`. + const namedView = { + listViews: { + won: { + label: 'Won', + filter: [['stage', '=', 'won']], + sort: [{ field: 'name', order: 'desc' }], + }, + }, + defaultListView: 'won', + }; + + it('keeps the named view filter in force over a table.filter', () => { + const grid = forwardedGridSchema({ + ...namedView, + table: { filter: [['stage', '=', 'lost']] } as any, + } as any); + expect(grid.filter).toEqual([['stage', '=', 'won']]); + expect(grid.defaultFilters).toBeUndefined(); + }); + + it('keeps the named view sort in force over a table.sort', () => { + // objectui#5102 pinned this as `sort: undefined` + + // `defaultSort: [{ field: 'name', order: 'desc' }]` and said in so many + // words that the pin should be UPDATED, not deleted, by whoever fixed the + // arity. Updated here: the array now rides the canonical slot — the only + // one of the two whose declared type (`SortConfig[]`) can hold more than + // one key — and the legacy slot carries the `table` segment alone. Precedence is what this block asserts and it is unchanged: + // ObjectGrid resolves `schemaSort ?? defaultSort`, so the named view wins + // over `table.sort` because it is what reaches the canonical slot. + const grid = forwardedGridSchema({ + ...namedView, + table: { sort: [{ field: 'created', order: 'asc' }] } as any, + } as any); + expect(grid.sort).toEqual([{ field: 'name', order: 'desc' }]); + expect(grid.defaultSort).toBeUndefined(); + }); + + it('keeps the named view sort in force, and a retired table.defaultSort beside it is not forwarded (objectui#5861)', () => { + // This used to assert the `table` segment rode the legacy slot beside the + // view's canonical one. The legacy slot is gone: the view sort still wins, + // and the retired key reaches ObjectGrid in neither slot. + const grid = forwardedGridSchema({ + ...namedView, + table: { defaultSort: { field: 'created', order: 'asc' } } as any, + } as any); + expect(grid.sort).toEqual([{ field: 'name', order: 'desc' }]); + expect(grid.defaultSort).toBeUndefined(); + }); + }); + + describe('non-grid path: ObjectView resolves the pair itself, canonical first', () => { + // Calendar / kanban / gallery / timeline fetch through ObjectView, so the + // precedence chosen for this card is directly observable on the wire here. + // + // ⚠️ objectui#4869 REWROTE the three `$orderby` expectations below, and the + // maintainer ruling of 2026-08-22 authorises exactly that. They used to pin + // the AUTHORED value reaching `$orderby` VERBATIM — which for the legacy + // `table.defaultSort` spelling was a live `400 INVALID_SORT`: a single + // `{ field, order }` object is read by the adapter as an `$orderby` MAP, so + // `Object.entries` folded it to the wire string `field,order`, two columns + // that do not exist, and the view came back EMPTY. This read site now lowers + // the whole chain through the shared sink (`convertSortToQueryParams`), so + // the assertions moved to the LOWERED value — the same value, in the one + // normalized shape every other object-bound block already sends. + // + // What objectui#5102 put here is NOT weakened by that: each expectation is + // still an exact `toEqual` on a fully specified map. What changed is the + // shape they arrive in, which is the defect objectui#4869 fixed. See + // `ObjectView.sortSink.test.tsx` for the read-site evidence. + // + // ⚠️ objectui#5861 then RETIRED the legacy half of the sort pair: the two + // cells below that pinned `table.defaultSort` as working now pin it inert — + // no `$orderby` from it alone, and no effect beside a canonical `table.sort`. + it('queries with a canonical table.filter', async () => { + const params = await queryParams(nonGridFind({ table: { filter: [['stage', '=', 'won']] } as any })); + expect(params.$filter).toEqual([['stage', '=', 'won']]); + }); + + it('lowers a ViewFilterRule[] table.filter through the shared sink', async () => { + // Same treatment the retired `table.defaultFilters` got — the canonical key + // reaches `$filter` as AST, not as bare rule objects (objectui#3431). + const params = await queryParams( + nonGridFind({ table: { filter: [{ field: 'stage', operator: 'eq', value: 'won' }] } as any }), + ); + expect(params.$filter).toEqual([['stage', 'equals', 'won']]); + }); + + it('queries with table.filter alone when a retired table.defaultFilters is written beside it', async () => { + const params = await queryParams( + nonGridFind({ + table: { filter: [['stage', '=', 'won']], defaultFilters: { stage: 'lost' } } as any, + }), + ); + expect(params.$filter).toEqual([['stage', '=', 'won']]); + }); + + it('a retired table.defaultFilters alone filters NOTHING (objectui#12030)', async () => { + // Flipped from `still queries with table.defaultFilters alone`, which + // asserted `['status', '=', 'active']`. The canonical control is `queries + // with a canonical table.filter` above, on this same path. + const params = await queryParams(nonGridFind({ table: { defaultFilters: { status: 'active' } } as any })); + expect(params.$filter).toBeUndefined(); + }); + + it('orders by a canonical table.sort', async () => { + const params = await queryParams(nonGridFind({ table: { sort: [{ field: 'name', order: 'desc' }] } as any })); + expect(params.$orderby).toEqual({ name: 'desc' }); + }); + + it('orders by table.sort alone when a retired table.defaultSort is written beside it', async () => { + const params = await queryParams( + nonGridFind({ + table: { sort: [{ field: 'name', order: 'desc' }], defaultSort: { field: 'created', order: 'asc' } } as any, + }), + ); + expect(params.$orderby).toEqual({ name: 'desc' }); + }); + + it('a retired table.defaultSort alone orders NOTHING (objectui#5861)', async () => { + // Flipped from `still orders by table.defaultSort alone`, which asserted + // `{ created: 'asc' }`. The key is an ADR-0049 tombstone the protocol + // refuses by name, so this read site no longer lowers it: the query goes + // out unsorted. The canonical control is `orders by a canonical + // table.sort` above, on this same path. + const params = await queryParams(nonGridFind({ table: { defaultSort: { field: 'created', order: 'asc' } } as any })); + expect(params.$orderby).toBeUndefined(); + }); + }); + + describe('delegated renderListView: canonical first, filter and sort aliases retired', () => { + it('hands over a canonical table.filter', () => { + expect(delegatedSchema({ table: { filter: [['stage', '=', 'won']] } as any }).filter) + .toEqual([['stage', '=', 'won']]); + }); + + it('hands over table.filter alone when a retired table.defaultFilters is written beside it', () => { + const s = delegatedSchema({ + table: { filter: [['stage', '=', 'won']], defaultFilters: { stage: 'lost' } } as any, + }); + expect(s.filter).toEqual([['stage', '=', 'won']]); + }); + + it('a retired table.defaultFilters alone hands over no filter (objectui#12030)', () => { + // Flipped from `still hands over table.defaultFilters alone`. The canonical + // control is `hands over a canonical table.filter` above. + expect(delegatedSchema({ table: { defaultFilters: { status: 'active' } } as any }).filter) + .toBeUndefined(); + }); + + it('hands over a canonical table.sort, and a retired table.defaultSort beside it changes nothing', () => { + const SORT = [{ field: 'name', order: 'desc' }]; + expect(delegatedSchema({ table: { sort: SORT } as any }).sort).toEqual(SORT); + const s = delegatedSchema({ + table: { sort: SORT, defaultSort: { field: 'created', order: 'asc' } } as any, + }); + expect(s.sort).toEqual(SORT); + }); + + it('hands over NO sort for a retired table.defaultSort alone (objectui#5861)', () => { + // Flipped from `still hands over table.defaultSort alone — WRAPPED`, which + // asserted `[{ field: 'created', order: 'asc' }]` (objectui#6235's wrap of + // the legacy single entry into the `SortConfig[]` the slot declares). The + // key is retired, so there is nothing to wrap: the delegated node gets no + // `sort`, exactly as if none had been authored. Canonical control: the + // cell above, on this same path. + expect(delegatedSchema({ table: { defaultSort: { field: 'created', order: 'asc' } } as any }).sort) + .toBeUndefined(); + }); + }); +}); + +// ── ObjectView.createGrant-12082 ─────────────────────────────────────────── + +/** + * objectui#12082 — the `object-view` toolbar's create button reads the + * `listNew` row of the affordance-to-grant map. + * + * Before, the button read the node's own `showCreate` and `operations.create` + * toggles and no grant at all, so a caller without the create grant was + * offered a New that opened a form the server then refused to save. The + * console's own list pages already read `listNew` (`ObjectView` / + * `ObjectDataPage` in `@object-ui/app-shell`); this is the same affordance on + * the SDUI `object-view` node, so it reads the same row. + * + * Measured through the REAL `MePermissionsProvider`, over the four grant + * shapes the map's enumeration pin uses. The authoring toggles still narrow: + * `showCreate: false` hides the button under the full grant. + */ +describe('ObjectView.createGrant-12082', () => { + restoreAfterBlock(); + + beforeEach(() => { + standIns.schemaRenderer = ({ schema }: any) =>
{schema?.type}
; + standIns.objectGrid = () =>
; + standIns.objectForm = () =>
; + }); + + afterEach(cleanup); + + const OBJECT = 'contacts'; + + const dataSource = (): DataSource => ({ + find: vi.fn().mockResolvedValue([]), + findOne: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({}), + update: vi.fn().mockResolvedValue({}), + delete: vi.fn().mockResolvedValue({}), + getObjectSchema: vi.fn().mockResolvedValue({ name: OBJECT, label: 'Contacts', fields: { name: { type: 'text' } } }), + } as unknown as DataSource); + + const SHAPES: Array<{ shape: string; bits: Record; create: boolean }> = [ + { shape: 'create-only', bits: { allowCreate: true, allowRead: true, allowEdit: false, allowDelete: false }, create: true }, + { shape: 'edit-only', bits: { allowCreate: false, allowRead: true, allowEdit: true, allowDelete: false }, create: false }, + { shape: 'read-only', bits: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, create: false }, + { shape: 'full', bits: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true }, create: true }, + ]; + + const envelope = (bits: Record): MePermissionsAuthenticatedResponse => ({ + authenticated: true, + userId: 'u-pin', + tenantId: null, + hasAnyQueue: false, + positions: ['member'], + permissionSets: ['member'], + objects: { [OBJECT]: bits }, + fields: {}, + }); + + const schema = (extra: Partial = {}): ObjectViewSchema => ({ type: 'object-view', objectName: OBJECT, ...extra }); + + describe('object-view create button reads the listNew row (objectui#12082)', () => { + it.each(SHAPES)('$shape grant: New shows exactly when the create grant allows it', async ({ bits, create }) => { + render( + + + , + ); + // Wait for the schema read to settle so the policy layer has its answer. + await screen.findByTestId('object-grid'); + expect(screen.queryByRole('button', { name: 'New' }) !== null).toBe(create); + }); + + it('fail-open: with no permission provider mounted, New shows as before', async () => { + render(); + await screen.findByTestId('object-grid'); + expect(screen.getByRole('button', { name: 'New' })).toBeDefined(); + }); + + it('the authoring toggle still narrows: showCreate false hides New under the full grant', async () => { + render( + + + , + ); + await screen.findByTestId('object-grid'); + expect(screen.queryByRole('button', { name: 'New' })).toBeNull(); + }); + + it('the effective operation set still narrows: an apiOperations set without create hides New under the full grant', async () => { + render( + + + , + ); + await screen.findByTestId('object-grid'); + expect(screen.queryByRole('button', { name: 'New' })).toBeNull(); + }); + }); +}); + +// ── ObjectView.calendarBinding-7029 ──────────────────────────────────────── + +/** + * objectui#7029 — the SECOND route to `ObjectCalendar` invents nothing either. + * + * Ruled on objectstack#13748 (director batch #19, option A): ⛔ either way no + * invented field names. The card measured the console route + * (`app-shell/ObjectView` → `plugin-list/ListView` → `ObjectCalendar`); this + * file covers the other one. `generateViewSchema` runs precisely when no host + * supplied `renderListView` — the authored `object-view` element, which is what + * `examples/schema-catalog`'s object-view fixtures drive — so it bypasses + * `ListView` entirely and carried its OWN copy of the fabrication: + * `startDateField: 'start_date'`, `endDateField: 'end_date'`, + * `titleField: 'name'` for a view that declared none. + * + * Fixing only the console route would have left this one rendering the same + * plausible, fully wrong screen the card exists to remove, and would have made + * the fix's own claim ("no invented field names") false in the repo it was + * merged into. + * + * REVERSE VERIFICATION — direction predicted before running, then observed: + * restore the three `||` floors in this branch and the "invents NO binding" + * case goes RED (it reads the fabricated names) while the declared-config + * CONTROL stays GREEN in either world. + */ +describe('ObjectView.calendarBinding-7029', () => { + restoreAfterBlock(); + + beforeEach(() => { + standIns.schemaRenderer = ({ schema }: any) => { + rendered.push(schema); + return
{schema?.type}
; + }; + standIns.objectGrid = () =>
; + standIns.objectForm = () =>
; + }); + + /** Every schema the view hands to SchemaRenderer, in order. */ + const rendered: any[] = []; + + async function renderCalendarView(view: Record) { + rendered.length = 0; + const ds: any = { + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ name: 'crm_leave_request', fields: {} }), + }; + render( + , + ); + await waitFor(() => expect(rendered.length).toBeGreaterThan(0)); + return rendered[rendered.length - 1]; + } + + /** + * The emitted binding. objectstack#25923: it goes out as the node's own + * `calendar` block, never flat (the spec refuses the five flat on + * `object-calendar`). The guard reds if a flat spelling comes back. + */ + const blockOf = (node: any): Record => { + for (const key of ['startDateField', 'endDateField', 'titleField', 'colorField', 'allDayField']) { + expect(node, `flat ${key} on the node`).not.toHaveProperty(key); + } + return node.calendar ?? {}; + }; + + describe('ObjectView.generateViewSchema — calendar restates only a DECLARED binding (objectui#7029)', () => { + it('invents NO binding for a calendar view that declares no config', async () => { + const schema = await renderCalendarView({}); + expect(schema.type).toBe('object-calendar'); + // All three used to be fabricated here. Absent bindings are the only route + // to `ObjectCalendar`'s refusal screen. + expect(blockOf(schema).startDateField).toBeUndefined(); + expect(blockOf(schema).endDateField).toBeUndefined(); + expect(blockOf(schema).titleField).toBeUndefined(); + }); + + it('CONTROL: forwards a declared calendar block unchanged', async () => { + // A correctly configured calendar renders exactly as it did before this + // card — without this case a fix that emitted nothing at all would look + // identical to the fix that was ruled. + const schema = await renderCalendarView({ + calendar: { startDateField: 'start_date', endDateField: 'end_date', titleField: 'subject' }, + }); + expect(schema.type).toBe('object-calendar'); + expect(blockOf(schema).startDateField).toBe('start_date'); + expect(blockOf(schema).endDateField).toBe('end_date'); + expect(blockOf(schema).titleField).toBe('subject'); + }); + + it('CONTROL: a partially declared block keeps its declared half and only that', async () => { + const schema = await renderCalendarView({ calendar: { startDateField: 'start_date' } }); + expect(blockOf(schema).startDateField).toBe('start_date'); + expect(blockOf(schema).endDateField).toBeUndefined(); + expect(blockOf(schema).titleField).toBeUndefined(); + }); + }); +}); + +// ── ObjectView.formTitleNoProviderFallback ───────────────────────────────── + +/** + * `ObjectView`'s create / edit / view form title still resolves to ENGLISH, and + * to the SAME BYTES as before, when no `I18nProvider` is mounted — + * objectui#3462. + * + * This is not a nice-to-have. Routing a literal through `t()` without a working + * default is exactly how a provider-less consumer breaks, and it breaks in a + * suite that is not this one: `object-view` renders wherever its type is + * registered, so any host that renders schema without mounting a provider (this + * package's own tests, the preview gallery, an embedding app) reads whatever the + * defaults map says. The English defaults live in `VIEW_DEFAULT_TRANSLATIONS` + * (`ObjectView.tsx`) — that map is what `createSafeTranslation` falls back to + * when its `detail.recordDetailWithLabel` probe comes back unresolved, and it is + * what interpolates `{{object}}`. + * + * The byte-identity matters beyond aesthetics: the heading is `View Contacts` + * before the change and `View Contacts` after, so e2e specs and host tests that + * address this chrome by its English name keep addressing it. + * + * Direction: this file was GREEN before the change and is GREEN after. It pins + * the FALLBACK, not the fix — the fix is asserted in + * `ObjectView.formTitleI18n.test.tsx`. A missing map entry would turn it red by + * rendering the raw key `form.viewTitle`, which is precisely the regression it + * exists to catch. + * + * ── Why it can share this file with a block that mounts a provider ──────── + * `createI18n` calls `instance.use(initReactI18next)`, and `initReactI18next` + * registers that instance as **react-i18next's module-global default**. The + * registration survives unmount and `cleanup()`. So once a test mounts + * ``, a later "no provider" + * render resolves against the Chinese instance unless something puts the global + * back, and this pin was first written as a file of its own for that reason. + * `vitest.setup.i18n-global.ts` (objectui#4514) now restores the global after + * every test, which is what lets this block run after + * `ObjectView.formLabelsI18nLabel-10993` above, a block that mounts a zh + * provider. Still: **do not import or mount `I18nProvider` in this block.** + */ +describe('ObjectView.formTitleNoProviderFallback', () => { + restoreAfterBlock(); + + beforeEach(() => { + standIns.schemaRenderer = ({ schema }: any) => ( +
+ {schema?.type} +
+ ); + standIns.objectGrid = ({ schema, onRowClick, onEdit }: any) => ( +
+ + +
+ ); + standIns.objectForm = ({ schema }: any) => ( +
+ Form ({schema?.mode}) +
+ ); + }); + + function dataSourceLabelled(label?: string): DataSource { + return { + find: vi.fn().mockResolvedValue({ data: [{ id: '1', name: 'Test' }], total: 1 }), + findOne: vi.fn().mockResolvedValue({ id: '1', name: 'Test' }), + create: vi.fn().mockResolvedValue({}), + update: vi.fn().mockResolvedValue({}), + delete: vi.fn().mockResolvedValue({}), + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'contacts', + ...(label ? { label } : {}), + fields: { name: { label: 'Name', type: 'text' } }, + }), + } as unknown as DataSource; + } + + function renderView(label?: string) { + return render( + , + ); + } + + const openView = () => fireEvent.click(screen.getByTestId('grid-row')); + const openEdit = () => fireEvent.click(screen.getByTestId('grid-edit')); + const openCreate = () => fireEvent.click(screen.getByRole('button', { name: 'New' })); + + afterEach(() => cleanup()); + + describe('ObjectView form title — English fallback with no provider (objectui#3462)', () => { + it('heads a view-mode drawer "View {label}", never the raw key', async () => { + renderView('Contacts'); + openView(); + + await waitFor(() => expect(screen.getByText('View Contacts')).toBeInTheDocument()); + expect(screen.queryByText('form.viewTitle')).toBeNull(); + }); + + it('heads an edit-mode drawer "Edit {label}", never the raw key', async () => { + renderView('Contacts'); + openEdit(); + + await waitFor(() => expect(screen.getByText('Edit Contacts')).toBeInTheDocument()); + expect(screen.queryByText('form.editTitle')).toBeNull(); + }); + + it('heads a create-mode drawer "Create {label}", never the raw key', async () => { + renderView('Contacts'); + openCreate(); + + await waitFor(() => expect(screen.getByText('Create Contacts')).toBeInTheDocument()); + expect(screen.queryByText('form.createTitle')).toBeNull(); + }); + + it('falls back to the objectName when the object declares no label', async () => { + renderView(); + openView(); + + await waitFor(() => expect(screen.getByText('View contacts')).toBeInTheDocument()); + }); + }); +}); diff --git a/ui/packages/plugin-view/src/__tests__/ObjectView.strayGroupByLane-9242.test.tsx b/ui/packages/plugin-view/src/__tests__/ObjectView.strayGroupByLane-9242.test.tsx deleted file mode 100644 index f1e78551b12..00000000000 --- a/ui/packages/plugin-view/src/__tests__/ObjectView.strayGroupByLane-9242.test.tsx +++ /dev/null @@ -1,313 +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#9242 — a stray `groupBy` in `viewOptions.kanban` OVERRODE the lane - * `generateViewSchema` had just resolved. THE SECOND ROUTE. - * - * `generateViewSchema`'s kanban branch destructured - * `columns`/`groupByField`/`groupField`/`titleField`/`conditionalFormatting` - * out of `viewOptions.kanban` and spread the REST *after* its own `groupBy`. So - * an authored `kanban.groupBy` survived into `restKanban` and won over the - * canonical `groupByField` the branch had already resolved — the board grouped - * by the stray key, and nothing said so. - * - * ## WHY THIS IS A SECOND ROUTE, NOT A DUPLICATE OF objectui#8365 - * - * `generateViewSchema` runs precisely when no host supplied `renderListView` — - * the authored `object-view` element — so it never passes through `ListView`, - * and PR objectui#9236's destructure fix does not reach it. This is the same - * distinction the `calendar` branch a few lines below records for objectui#7029. - * - * ## THE RULING — already made; this card only applies it to the second route - * - * Maintainer ruling of 2026-09-12 (decision batch #117 item 5, verbatim - * 「8365 同意」) — option B: the CANONICAL spelling wins and the stray key is - * refused BY NAME. Option A (strip the key and re-group in silence) was NOT - * taken. ⛔ This is not an open question; see objectui#8365 / PR objectui#9236. - * - * ## THE DISTINGUISHING FIXTURE, and why one lane name would not do - * - * The two spellings carry two DIFFERENT lane names. With a single name the - * override arm cannot fail BY CONSTRUCTION — which is exactly how the twin - * defect stayed invisible for so long on the first route: the one producer that - * fed it wrote both spellings with the same value. - * - * kanban = { groupBy: 'LANE_FROM_STRAY_GROUPBY', - * groupByField: 'LANE_FROM_CANONICAL' } - * - * ## ⭐ THE ARM THAT IS **MEASURED HERE**, NOT COPIED FROM THE TWIN - * - * `ListView` floors its lane on `detectStatusField(objectDef)`. THIS branch - * floors it on the LITERAL `'status'` (`groupByField || groupField || 'status'`), - * so the "stray key alone" control answers differently on the two routes and - * copying the twin's assertion would have pinned the wrong thing. - * - * Measured on this tree before this file was written (base `96919a459`, every - * lit control firing), `node.groupBy` per fixture: - * - * | kanban config | BEFORE fix | AFTER fix | - * | -------------------------------------- | ---------- | ---------- | - * | `{ groupBy: S, groupByField: C }` | **S** ⛔ | C | - * | `{ groupBy: S }` | **S** ⛔ | `'status'` | - * | `{ groupBy: S }`, object has NO status | **S** ⛔ | `'status'` | - * | `{}` | `'status'` | `'status'` | - * | `{ groupByField: C }` | C | C | - * | `{ groupField: C }` | C | C | - * - * The third row is the one that tells a LITERAL floor apart from a detector: an - * object declaring no `status` field still floors at `'status'` here, where the - * twin would have produced `undefined`. Pinned below for exactly that reason. - * - * ## WHAT THIS CARD DELIBERATELY DOES NOT TOUCH — four faces - * - * 1. THE CONTRACT HALF — AND WHAT IT ACTUALLY REACHES. `KanbanConfig`'s - * `aliasKeyRefusal` for `groupBy` (`@object-ui/types`, `zod/objectql.zod.ts`) - * landed with PR objectui#9236, and is pinned in - * `packages/plugin-list/src/__tests__/ListView.strayGroupByRefused-8365.test.tsx` - * (runtime face) and - * `packages/types/src/__tests__/kanban-stray-group-by-refusal-8365.test.ts` - * (type face). ⛔ That arm alone does NOT cover both routes, and this note - * used to say it did (objectui#9655). This route's door is a second install - * site of the same string, added by objectui#10321: `ObjectViewSchema`'s - * named-view check refuses `listViews.KEY.kanban.groupBy` in both nestings, - * pinned in - * `packages/types/src/__tests__/named-view-kanban-stray-group-by-10321.test.ts`. - * - * ⭐ READ THE REACH, ⛔ DO NOT TRUST A COUNT WRITTEN DOWN HERE — a count in - * prose is derived once and re-derived never, and the one that stood here - * was wrong. Every place the arm reaches an authored document is a - * reference to the `KanbanStrayGroupByRefusal` symbol, so the instrument is - * an enumeration of that symbol: - * - * git grep -n KanbanStrayGroupByRefusal packages/types/src - * - * The declaration line is that run's own positive control — a run that does - * not print it found nothing at all, rather than finding no install sites. - * Read which object each hit hangs off; the arm's own "WHERE THIS ARM IS - * INSTALLED" note in that file states the routes and nestings it was - * written for. The first two pins above build `list-view` fixtures and the - * third `object-view` ones, which is the same reading taken from the other - * side. - * - * ⚠️ THE MODEL ERROR, NOT JUST THE SENTENCE. The claim that made the wrong - * one feel safe to write was the tail of this item: that what remains on - * this route is "a document that never went through a validator". ⛔ FALSE. - * `safeValidateSchema` DOES judge an `object-view` document — it ACCEPTED - * this one until objectui#10321, and refuses it now. The instrument is next - * door in this directory: - * `ObjectView.calendarAliasRefused-8355.test.tsx`'s "REFUSED at the read - * door" half drives a named view under `listViews` through - * `safeValidateSchema` and gets a refusal — same door, same document shape, - * a different key family. ⇒ write ACCEPTED, ⛔ never "not validated": - * "not refused" is the weaker and true claim, and the substitution of the - * stronger one got written down three times in this one neighbourhood — - * here; in `KanbanStrayGroupByRefusal`'s own PUBLISHED message, which an - * author still reads today (objectui#9653, ⛔ not this card's file); and in - * objectui#8355's calendar stem, where a contract review caught the clause - * before it landed (objectui#9648). - * - * ⛔ Still not re-pinned here: this file pins the BEHAVIOUR half, which - * holds for any document that reaches the branch carrying the key, whether - * or not it passed a validator. The door is pinned where it is declared (the - * `types` file above). Which value type `listViews` itself should enforce is - * objectui#7928's question, and neither this card nor objectui#10321 - * answers it. - * 2. THE LIVE LEGACY ALIAS `kanban.groupField`, a legacy spelling of the spec's - * `groupByField`. Still read, still resolves the lane — CONTROL below. - * 3. `groupBy` ON THE GENERATED NODE. That is the canonical lane key - * `ObjectKanban` actually reads (thirteen `schema.groupBy` reads in - * `ObjectKanban.tsx`); the stray key is a VIEW-CONFIG spelling. Do not - * confuse the two — the whole fix is to stop the second from reaching the - * first. - * 4. `ListView` — objectui#8365's face, closed. - * - * ## REVERSE VERIFICATION (the ablation leg), direction predicted before running - * - * Run in its STRONGEST form: this file was written and run BEFORE - * `ObjectView.tsx` was touched, so the "unmodified" arm is the REAL BASE TREE - * rather than an injected mutation — no on-disk mutation to prove, no restore - * leg to get wrong. Predicted and then observed on `96919a459`: the three - * override arms go RED naming `LANE_FROM_STRAY_GROUPBY`, while every CONTROL - * arm (canonical, legacy alias, floor, passthrough, sibling keys) stays GREEN. - * The asymmetry is what shows the controls are not carrying the override arms. - */ - -import React from 'react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, waitFor, cleanup } from '@testing-library/react'; -import { ObjectView } from '../ObjectView'; -import type { ObjectViewSchema } from '@object-ui/types'; - -/** The two lane names the fixture holds apart. */ -const STRAY = 'LANE_FROM_STRAY_GROUPBY'; -const CANONICAL = 'LANE_FROM_CANONICAL'; -/** The literal this branch floors the lane at — NOT a detector. */ -const FLOOR = 'status'; - -/** Every schema the view hands to SchemaRenderer, in order. */ -const rendered: any[] = []; - -vi.mock('@object-ui/react', async (importOriginal) => { - const ReactMod = await import('react'); - return { - ...(await importOriginal>()), - SchemaRenderer: ({ schema }: any) => { - rendered.push(schema); - return
{schema?.type}
; - }, - SchemaRendererContext: ReactMod.createContext(null), - subscribeDataChanges: () => () => {}, - notifyDataChanged: () => {}, - }; -}); -vi.mock('@object-ui/plugin-grid', async (importOriginal) => ({ - ...(await importOriginal>()), - ObjectGrid: () =>
, -})); -vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ - ...(await importOriginal>()), - ObjectForm: () =>
, -})); - -/** An object that DOES declare a `status` field. */ -const WITH_STATUS = { status: { name: 'status', type: 'text', label: 'Status' } }; -/** An object that declares NO `status` field — tells a literal floor from a detector. */ -const WITHOUT_STATUS = { name: { name: 'name', type: 'text', label: 'Name' } }; - -const dataSource = (fields: Record = WITH_STATUS): any => ({ - find: vi.fn().mockResolvedValue({ data: [], total: 0 }), - findOne: vi.fn(), - create: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - getObjectSchema: vi.fn().mockResolvedValue({ name: 'task', fields }), -}); - -/** - * Renders a kanban view through the REGISTERED renderer's path — i.e. with no - * `renderListView` — and returns the `object-kanban` node the branch emits. - * - * ⚠️ `cleanup()` is load-bearing, not hygiene. Without it the previously - * mounted `ObjectView`s stay mounted and keep pushing into `rendered`, so - * `rendered[rendered.length - 1]` returns a STALE node and every reading comes - * back shifted by one fixture. That failure was hit while measuring this card: - * it renders as a plausible table in which the lit controls quietly answer the - * previous row's question. ⛔ Do not drop it. - */ -async function generatedKanbanNode( - kanban: Record, - fields?: Record, -): Promise { - cleanup(); - rendered.length = 0; - render( - , - ); - await waitFor(() => expect(rendered.length).toBeGreaterThan(0)); - const node = rendered[rendered.length - 1]; - expect(node.type).toBe('object-kanban'); - return node; -} - -beforeEach(() => { - rendered.length = 0; -}); - -describe('objectui#9242 — the CANONICAL lane wins on the node `generateViewSchema` emits', () => { - it('the distinguishing fixture resolves the lane from `groupByField`, not the stray `groupBy`', async () => { - const node = await generatedKanbanNode({ groupBy: STRAY, groupByField: CANONICAL }); - // Before the fix this read `LANE_FROM_STRAY_GROUPBY`: the `...restKanban` - // spread landed after the branch's own `groupBy`. - expect(node.groupBy).toBe(CANONICAL); - expect(node.groupBy).not.toBe(STRAY); - }); - - it('the stray VALUE does not reach the generated node under any key at all', async () => { - const node = await generatedKanbanNode({ groupBy: STRAY, groupByField: CANONICAL }); - // The assertion above is about the lane key's VALUE. This one is about the - // stray value: it must appear nowhere on the node, including under some - // other key the spread might have carried it through. - expect(Object.values(node)).not.toContain(STRAY); - expect(Object.prototype.hasOwnProperty.call(node, 'groupByField')).toBe(false); - }); - - it('LANE CONTROL (measured here, ⛔ not copied from `ListView`): `groupBy` alone floors at the literal', async () => { - // ⭐ THE ARM THE CARD SINGLED OUT. `ListView` answers this with - // `detectStatusField(objectDef)`; this branch answers with the LITERAL - // `'status'`. Option B, not a silent re-grouping: the stray key stops - // steering the board, and what replaces it is this branch's own floor. - const node = await generatedKanbanNode({ groupBy: STRAY }); - expect(node.groupBy).toBe(FLOOR); - expect(node.groupBy).not.toBe(STRAY); - }); - - it('LANE CONTROL: the floor is a LITERAL — it holds when the object declares no `status` field', async () => { - // This is the row that tells a literal floor apart from a detector, and so - // the row that would redden first if anyone ever "aligned" this branch with - // the twin by swapping in `detectStatusField`. That would be a behaviour - // change on stored views and belongs on its own card, not in a tidy-up. - const node = await generatedKanbanNode({ groupBy: STRAY }, WITHOUT_STATUS); - expect(node.groupBy).toBe(FLOOR); - expect(node.groupBy).not.toBe(STRAY); - expect(node.groupBy).toBeDefined(); - }); -}); - -describe('objectui#9242 — the controls, each able to fire on its own', () => { - it('CONTROL: the declared `kanban.groupByField` still resolves the lane by itself', async () => { - const node = await generatedKanbanNode({ groupByField: CANONICAL }); - expect(node.groupBy).toBe(CANONICAL); - }); - - it('the retired legacy alias `kanban.groupField` names no lane: the floor answers (objectui#6152 round 14)', async () => { - // This card left the VIEW-LEVEL alias live (only the third spelling was - // stripped), and this arm pinned that it still resolved the lane. - // objectui#6152 round 14 retired the alias read on its own ruling, so a - // stored `groupField` now gets the same lane as a block with no key. - const node = await generatedKanbanNode({ groupField: CANONICAL }); - expect(node.groupBy).toBe(FLOOR); - expect(node.groupBy).not.toBe(CANONICAL); - }); - - it('CONTROL: with neither declared key the lane is the floor, exactly as before this card', async () => { - const node = await generatedKanbanNode({}); - expect(node.groupBy).toBe(FLOOR); - }); - - it('DECLARED-KEYS CONTROL: a declared sibling key reaches the node; an undeclared one does not (objectui#6152 round 14)', async () => { - // This card stripped exactly ONE more key and kept `restKanban` as the - // passthrough, and this arm pinned that an undeclared key still rode - // through. objectui#6152 round 14 retired the passthrough on purpose: the - // branch forwards the spec kanban block's keys by name, so the declared - // `summarizeField` still arrives and an undeclared key does not. - const node = await generatedKanbanNode({ groupByField: CANONICAL, summarizeField: 'amount', zzzBogusKey: 'rides-through' }); - expect(node.summarizeField).toBe('amount'); - expect(node).not.toHaveProperty('zzzBogusKey'); - expect(node.groupBy).toBe(CANONICAL); - }); - - it('CONTROL: the rest of the kanban branch still resolves — titleField and cardFields', async () => { - // Without this arm every assertion above would pass just as happily against - // a kanban branch that had stopped emitting anything at all. - const node = await generatedKanbanNode({ - groupByField: CANONICAL, - columns: ['name', 'amount'], - titleField: 'name', - }); - expect(node.titleField).toBe('name'); - expect(node.cardFields).toEqual(['name', 'amount']); - // `columns` on the node would be read as LANES by `object-kanban`, so the - // branch maps it to `cardFields` and strips it. Unchanged by this card. - expect(node.columns).toBeUndefined(); - }); -}); diff --git a/ui/packages/plugin-view/src/__tests__/ObjectView.tableColumnsForwarding.test.tsx b/ui/packages/plugin-view/src/__tests__/ObjectView.tableColumnsForwarding.test.tsx deleted file mode 100644 index 7e402d63a8f..00000000000 --- a/ui/packages/plugin-view/src/__tests__/ObjectView.tableColumnsForwarding.test.tsx +++ /dev/null @@ -1,298 +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#5269 — `table.columns` reaches the NON-grid paths too. - * - * `ObjectViewSchema.table` is `Partial< Omit< ObjectGridSchema, 'type' | - * 'objectName' > >`, where `columns` is the canonical spelling and `fields` - * carries "@deprecated Use columns instead". `ObjectView` does not spread - * `table`; it forwards a hand-written whitelist, and that whitelist had ONE - * read of `table.columns` in the whole file — the grid one. Both other field- - * list read points took the deprecated key alone: - * - * generateViewSchema `baseProps` `table.fields` only - * delegated `renderListView` schema `table.fields` only - * - * So `table: { columns: [...] }` — the shape the type recommends — produced an - * empty field list off the grid path, from a schema that compiled and read - * correctly. Same silent-success shape as objectui#5102; different mechanism: - * not a whitelist that knows only legacy spellings, but one that disagreed - * with itself between two rendering paths. - * - * ## What this file pins, and on which surface - * - * Both changed sites are pinned where a CONSUMER can be observed, not merely - * where the value is emitted: - * - * `baseProps` -> `object-kanban` consumed as `cardFields` - * (`ObjectKanban` reads `schema.cardFields`) - * `baseProps` -> `object-tree` consumed as `fields` - * (`ObjectTree.getTreeConfig` reads `schema.fields`) - * delegated -> `list-view` consumed as `columns` (`ListView`'s column set) - * - * ## The surfaces that get NO test here, deliberately - * - * `baseProps` also feeds `object-gallery`, `object-calendar`, `object-timeline`, - * `object-gantt` and `object-map`. Measured on this tree, none of those five - * renderers reads a field list off its schema at all — every `.fields` in them - * is `objectDef.fields` (object METADATA) or gallery's `schema.grouping.fields`. - * So the value this fix forwards is inert on those five, before the fix and - * after it. Asserting `fields` on their generated schema would pin the emit - * and prove nothing about the surface, so it is stated here instead of tested. - * (The card objectui#5269 names kanban / gallery / calendar as the affected - * surfaces; the measurable set is kanban / tree / delegated.) - * - * ## One asymmetry between the two changed sites - * - * `table.columns` is `string[] | ListColumn[]`. The delegated `list-view` - * slot declares that same union and `ListView` reads both, so it takes the - * value RAW — flattening there would throw away the width/label the author - * wrote. The non-grid `baseProps` slot is a names slot (both segments ahead of - * `table` declare `string[]`; `ObjectKanban` indexes records by each entry), so - * the `ListColumn[]` half is resolved to names with `columnIdentity` — the - * same fold `ObjectGrid` applies to this very value. Both halves are pinned. - * - * ## Precedence - * - * Unchanged, and pinned below: the two segments ahead of `table` - * (`listViews` entry, then `activeView`) still outrank it. Within the `table` - * segment the canonical key wins and the deprecated one keeps working — the - * same rule objectui#5102 applied to its four pairs. - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, waitFor } from '@testing-library/react'; -import { ObjectView } from '../ObjectView'; -import type { ObjectViewSchema } from '@object-ui/types'; - -/** Every schema the view hands to SchemaRenderer, in order. */ -const rendered: any[] = []; - -vi.mock('@object-ui/react', async (importOriginal) => { - const React = await import('react'); - return { - ...(await importOriginal>()), - SchemaRenderer: ({ schema }: any) => { - rendered.push(schema); - return
{schema?.type}
; - }, - SchemaRendererContext: React.createContext(null), - subscribeDataChanges: () => () => {}, - notifyDataChanged: () => {}, - }; -}); - -/** The grid the view delegates to, replaced by a probe that records its schema. */ -const gridSchemas: any[] = []; -vi.mock('@object-ui/plugin-grid', async (importOriginal) => ({ - ...(await importOriginal>()), - ObjectGrid: ({ schema }: any) => { - gridSchemas.push(schema); - return
; - }, -})); -vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ - ...(await importOriginal>()), - ObjectForm: () =>
, -})); - -const mockDataSource = () => ({ - find: vi.fn().mockResolvedValue({ data: [], total: 0 }), - findOne: vi.fn(), - create: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - getObjectSchema: vi.fn().mockResolvedValue({ name: 'task', fields: {} }), -}); - -/** - * Render a non-grid view and return the schema `generateViewSchema` produced. - * `views` drives the view types `defaultViewType` cannot spell (`tree`). - */ -async function generatedSchema( - schema: Partial, - views?: any[], -): Promise { - rendered.length = 0; - render( - , - ); - await waitFor(() => expect(rendered.length).toBeGreaterThan(0)); - return rendered[rendered.length - 1]; -} - -/** Render through the delegated `renderListView` slot and return its schema. */ -function delegatedSchema(schema: Partial): any { - const seen: any[] = []; - render( - { seen.push(s); return
; }} - />, - ); - return seen[0]; -} - -/** Render on the grid path and return the schema ObjectGrid was handed. */ -function forwardedGridSchema(schema: Partial): any { - gridSchemas.length = 0; - render( - , - ); - return gridSchemas[0]; -} - -const KANBAN = { defaultViewType: 'kanban' as const }; -const TREE_VIEWS = [{ id: 'tree-1', label: 'Tree', type: 'tree' as const }]; - -beforeEach(() => { - vi.clearAllMocks(); - rendered.length = 0; - gridSchemas.length = 0; -}); - -describe('kanban: a canonical table.columns reaches the cards (objectui#5269)', () => { - it('forwards table.columns as the kanban card fields', async () => { - // Before the fix this arrived as `cardFields: []` — the card fields the - // author declared were dropped at the forwarding hop, not at the renderer. - const s = await generatedSchema({ ...KANBAN, table: { columns: ['stage', 'owner'] } as any }); - expect(s.type).toBe('object-kanban'); - expect(s.cardFields).toEqual(['stage', 'owner']); - expect(s.fields).toEqual(['stage', 'owner']); - }); - - it('still forwards the deprecated table.fields', async () => { - // The alias must keep working — every view authored against today's docs - // writes it. (Green before AND after the fix: this pins the half that must - // NOT change, not the defect.) - const s = await generatedSchema({ ...KANBAN, table: { fields: ['stage'] } as any }); - expect(s.cardFields).toEqual(['stage']); - }); - - it('prefers the canonical table.columns over the deprecated table.fields', async () => { - const s = await generatedSchema({ - ...KANBAN, - table: { columns: ['stage'], fields: ['owner'] } as any, - }); - expect(s.cardFields).toEqual(['stage']); - }); - - it('resolves the ListColumn[] form of table.columns to field NAMES', async () => { - // `ObjectGridSchema.columns` is `string[] | ListColumn[]`, but this slot is - // a names slot: `resolveKanbanCardFields` casts to `string[]` and the card - // loop indexes the record by each entry. Forwarding the objects raw would - // produce a non-empty card field list naming nothing — and a non-empty list - // suppresses ObjectKanban's `highlightFields` fallback, so the cards would - // come out emptier than before this card. Resolved at the boundary with - // `columnIdentity`, the same fold ObjectGrid applies to this very value. - const s = await generatedSchema({ - ...KANBAN, - table: { columns: [{ field: 'stage', label: 'Stage', width: 120 }] } as any, - }); - expect(s.cardFields).toEqual(['stage']); - }); - - it('falls through to table.fields when no column entry names a field', async () => { - // The empty-array trap: `[] || x` is `[]`, so a `columns` whose entries all - // fail to resolve must yield `undefined`, not an empty list that stops the - // chain on a falsy-looking truthy value. - const s = await generatedSchema({ - ...KANBAN, - table: { columns: [{ label: 'no identity here' }], fields: ['owner'] } as any, - }); - expect(s.cardFields).toEqual(['owner']); - }); - - it('keeps a named view outranking the table segment', async () => { - // Precedence is unchanged by this card and must stay that way: the two - // segments ahead of `table` still win. (Green on both legs — a guard on - // the fix, not evidence of it.) - const s = await generatedSchema({ - ...KANBAN, - listViews: { mine: { label: 'Mine', columns: ['assignee'] } }, - defaultListView: 'mine', - table: { columns: ['stage'] } as any, - } as any); - expect(s.cardFields).toEqual(['assignee']); - }); -}); - -describe('tree: a canonical table.columns reaches the flat columns (objectui#5269)', () => { - it('forwards table.columns as the tree view fields', async () => { - const s = await generatedSchema({ table: { columns: ['stage', 'owner'] } as any }, TREE_VIEWS); - expect(s.type).toBe('object-tree'); - expect(s.fields).toEqual(['stage', 'owner']); - }); - - it('still forwards the deprecated table.fields', async () => { - const s = await generatedSchema({ table: { fields: ['stage'] } as any }, TREE_VIEWS); - expect(s.fields).toEqual(['stage']); - }); -}); - -describe('delegated renderListView: canonical first, alias still working (objectui#5269)', () => { - it('hands over a canonical table.columns as the list-view columns', () => { - expect(delegatedSchema({ table: { columns: ['stage', 'owner'] } as any }).columns) - .toEqual(['stage', 'owner']); - }); - - it('hands over the ListColumn[] form of table.columns unchanged', () => { - // `ObjectGridSchema.columns` (and `list-view`'s own `columns`) are both - // `string[] | ListColumn[]`, so the object form is a declared shape on - // this slot and must not be flattened on the way through. - const cols = [{ field: 'stage', label: 'Stage', width: 200 }]; - expect(delegatedSchema({ table: { columns: cols } as any }).columns).toEqual(cols); - }); - - it('still hands over the deprecated table.fields', () => { - expect(delegatedSchema({ table: { fields: ['stage'] } as any }).columns).toEqual(['stage']); - }); - - it('prefers the canonical table.columns over the deprecated table.fields', () => { - expect(delegatedSchema({ table: { columns: ['stage'], fields: ['owner'] } as any }).columns) - .toEqual(['stage']); - }); - - it('keeps a named view outranking the table segment', () => { - const s = delegatedSchema({ - listViews: { mine: { label: 'Mine', columns: ['assignee'] } }, - defaultListView: 'mine', - table: { columns: ['stage'] } as any, - } as any); - expect(s.columns).toEqual(['assignee']); - }); -}); - -describe('grid path: unchanged by this card — the control', () => { - it('keeps emitting table.columns and table.fields in their own slots', () => { - // The grid path forwards BOTH slots and lets `ObjectGrid` arbitrate - // (objectui#5102's shape). This card does not touch it; the assertion is - // here so a future "make all three sites identical" tidy-up cannot - // collapse the two slots without a red test. - // - // The `undefined` half is counter-probed by the populated half in the same - // render: `fields: undefined` alone would also be what a probe that saw no - // grid schema at all would report. - const canonicalOnly = forwardedGridSchema({ table: { columns: ['stage'] } as any }); - expect(canonicalOnly.columns).toEqual(['stage']); - expect(canonicalOnly.fields).toBeUndefined(); - - const legacyOnly = forwardedGridSchema({ table: { fields: ['owner'] } as any }); - expect(legacyOnly.fields).toEqual(['owner']); - expect(legacyOnly.columns).toBeUndefined(); - }); -});