From ce4e8f4c3ba251a084c74ac04de299c9102b8b9a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 07:42:10 +0000 Subject: [PATCH 1/3] fix(plugin-list): the columns-only candidate fallback reads no relational key off a list column Before the object definition loads, ListView builds its filter and sort candidates from the declared columns. That branch read reference_to / reference, display_field / reference_field and id_field off each column; ListColumnSchema declares none of them in either casing. The reads are removed per the ruling: a relational target comes from the object definition once it loads. Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC Co-authored-by: Claude --- .../7531-listview-column-undeclared-reads.md | 20 +++ packages/plugin-list/src/ListView.tsx | 12 +- ...w.columnsOnlyRelationalReads-7531.test.tsx | 159 ++++++++++++++++++ 3 files changed, 180 insertions(+), 11 deletions(-) create mode 100644 .changeset/7531-listview-column-undeclared-reads.md create mode 100644 packages/plugin-list/src/__tests__/ListView.columnsOnlyRelationalReads-7531.test.tsx diff --git a/.changeset/7531-listview-column-undeclared-reads.md b/.changeset/7531-listview-column-undeclared-reads.md new file mode 100644 index 0000000000..9ecc666876 --- /dev/null +++ b/.changeset/7531-listview-column-undeclared-reads.md @@ -0,0 +1,20 @@ +--- +'@object-ui/plugin-list': patch +--- + +`ListView` no longer reads relational keys off a list column while the object +definition is still loading (objectui#7531). + +Until the definition arrives, the filter and sort candidates are built from the +view's declared `columns`. That fallback used to read `reference_to` / +`reference`, `display_field` / `reference_field` and `id_field` off each +column. `ListColumnSchema` declares none of them in either casing, so a +spec-compliant producer can never put one there, and the read only kept a +second, undeclared route alive (AGENTS.md 0.1). The candidate now carries what +a column declares; its relational target (`referenceTo`, `displayField`, +`idField`) comes from the object definition once it loads, as it already did. + +Nothing changes for a spec-compliant view. A column that carried one of these +keys anyway now gets the same pre-load candidate as a column without it: the +filter's lookup value picker waits for the definition instead of taking the +column's word for the target. diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 5aa0118263..03798ad2b3 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -3668,17 +3668,7 @@ export const ListView = React.forwardRef(({ label: tFieldLabel(fieldName, f.label || fieldName), type: f.type || 'text', options: buildOptions(fieldName, f.options), - // objectui#7642 CENSUS — verdict KEEP, and NOTE the bag differs from - // the sibling branch below: `f` here is a LIST-VIEW COLUMN - // (`ListColumnSchema`), not an object-schema field def. Measured against - // the installed spec, `ListColumnSchema` refuses BOTH castings of all - // three keys (`display_field` AND `displayField`, `id_field` AND - // `idField`, `reference_to` AND `reference`), so this is not a - // snake-vs-camel question at all — it is a third contract. Filed - // separately rather than half-retired here. - referenceTo: f.reference_to || f.reference, - displayField: f.display_field || f.reference_field, - idField: f.id_field, + // objectui#7531 (ruled): a list column declares no relational target; it comes from the object definition once loaded. }]; }); } else { diff --git a/packages/plugin-list/src/__tests__/ListView.columnsOnlyRelationalReads-7531.test.tsx b/packages/plugin-list/src/__tests__/ListView.columnsOnlyRelationalReads-7531.test.tsx new file mode 100644 index 0000000000..29e4677694 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.columnsOnlyRelationalReads-7531.test.tsx @@ -0,0 +1,159 @@ +/** + * 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#7531 — the columns-only candidate fallback reads no relational key + * off a list column. + * + * Before the object definition loads, `ListView` builds its filter and sort + * candidates from the view's declared `columns`. That branch used to read + * `reference_to` / `reference`, `display_field` / `reference_field` and + * `id_field` off each column, and `ListColumnSchema` declares none of them in + * either casing, so a spec-compliant producer can never put one there. The + * ruling (5809501335, letter "remove") took the reads out: a relational target + * comes from the object definition once it loads, never from a column. + * + * The regression window is the pre-load one, so every render here holds + * `getObjectSchema` pending on purpose. A test that let the definition load + * would read the other branch and pass whatever this one does. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { ListColumnSchema } from '@objectstack/spec/ui'; +import type { ListViewSchema } from '@object-ui/types'; +import { SchemaRendererProvider } from '@object-ui/react'; + +// What ListView hands the filter builder: the candidate list itself, rather +// than whichever value picker the builder happens to draw from it. +const captured = vi.hoisted(() => ({ fields: [] as any[][] })); +vi.mock('@object-ui/components', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + FilterBuilder: (props: { fields: any[] }) => { + captured.fields.push(props.fields); + return null; + }, + }; +}); + +import { ListView } from '../ListView'; + +/** The five spellings the fallback used to read, none declared on a column. */ +const UNDECLARED_RELATIONAL_KEYS = { + reference_to: 'account', + reference: 'account', + display_field: 'name', + reference_field: 'name', + id_field: 'id', +} as const; + +const PLAIN_COLUMNS = [ + { field: 'account_id', label: 'Account', type: 'lookup' }, + { field: 'name', label: 'Name' }, +]; + +const COLUMNS_CARRYING_KEYS = [ + { ...PLAIN_COLUMNS[0], ...UNDECLARED_RELATIONAL_KEYS }, + PLAIN_COLUMNS[1], +]; + +function makeDataSource(getObjectSchema: () => Promise) { + return { + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(getObjectSchema), + }; +} + +async function renderAndOpenFilter(columns: unknown[], getObjectSchema: () => Promise) { + captured.fields.length = 0; + const dataSource = makeDataSource(getObjectSchema); + const schema = { + type: 'list-view', + objectName: 'crm_contact', + viewType: 'grid', + columns, + } as unknown as ListViewSchema; + render( + + + , + ); + await waitFor(() => expect(dataSource.getObjectSchema).toHaveBeenCalled()); + fireEvent.click(screen.getByRole('button', { name: /^filter/i })); + await screen.findByText('Filter Records'); + await waitFor(() => expect(captured.fields.length).toBeGreaterThan(0)); + return dataSource; +} + +const latestCandidates = () => captured.fields[captured.fields.length - 1]; + +/** A definition that never arrives: the columns-only branch is the only one. */ +const neverLoads = () => new Promise(() => {}); + +afterEach(() => { + cleanup(); + captured.fields.length = 0; +}); + +describe('objectui#7531 — no relational key is read off a list column', () => { + it('the spec refuses every one of the five spellings on a column (lit control: the bare column parses)', () => { + expect(ListColumnSchema.safeParse(PLAIN_COLUMNS[0]).success).toBe(true); + for (const [key, value] of Object.entries(UNDECLARED_RELATIONAL_KEYS)) { + const result = ListColumnSchema.safeParse({ ...PLAIN_COLUMNS[0], [key]: value }); + expect(result.success, key).toBe(false); + expect(result.error?.issues.map((i) => i.code), key).toContain('unrecognized_keys'); + } + }); + + it('before the object definition loads, a column carrying them offers the same candidates as a plain one', async () => { + await renderAndOpenFilter(PLAIN_COLUMNS, neverLoads); + const plain = latestCandidates(); + cleanup(); + + await renderAndOpenFilter(COLUMNS_CARRYING_KEYS, neverLoads); + const carrying = latestCandidates(); + + // Lit control: these ARE the columns-only candidates — the declared + // columns, in order, labelled by the column, with no definition behind them. + expect(plain.map((f: any) => [f.value, f.label, f.type])).toEqual([ + ['account_id', 'Account', 'lookup'], + ['name', 'Name', 'text'], + ]); + + expect(carrying).toStrictEqual(plain); + for (const key of ['referenceTo', 'displayField', 'idField']) { + expect(carrying[0][key], key).toBeUndefined(); + } + }); + + it('the relational target arrives with the object definition (lit control: the capture sees `referenceTo` when one exists)', async () => { + let deliver!: (def: unknown) => void; + const definition = new Promise((resolve) => { deliver = resolve; }); + await renderAndOpenFilter(COLUMNS_CARRYING_KEYS, () => definition); + + expect(latestCandidates()[0].referenceTo).toBeUndefined(); + + deliver({ + name: 'crm_contact', + fields: { + account_id: { type: 'lookup', label: 'Account', reference: 'crm_account' }, + name: { type: 'text', label: 'Name' }, + }, + }); + + // The definition's own target, not the column's `account`. + await waitFor(() => expect(latestCandidates()[0].referenceTo).toBe('crm_account')); + }); +}); From 6f6a411f9604c0cb5a48f5b40eb891dc097bf3e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 07:43:02 +0000 Subject: [PATCH 2/3] test(plugin-list): split the objectui#7531 pin from its two lit controls The plain-column control and the definition-supplies-the-target control now stand as their own cases, so an ablation that restores the column reads turns only the pin red. Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC Co-authored-by: Claude --- ...w.columnsOnlyRelationalReads-7531.test.tsx | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/plugin-list/src/__tests__/ListView.columnsOnlyRelationalReads-7531.test.tsx b/packages/plugin-list/src/__tests__/ListView.columnsOnlyRelationalReads-7531.test.tsx index 29e4677694..9adfeccf4e 100644 --- a/packages/plugin-list/src/__tests__/ListView.columnsOnlyRelationalReads-7531.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.columnsOnlyRelationalReads-7531.test.tsx @@ -117,20 +117,26 @@ describe('objectui#7531 — no relational key is read off a list column', () => } }); - it('before the object definition loads, a column carrying them offers the same candidates as a plain one', async () => { + it('control: before the definition loads, a plain column yields the columns-only candidate', async () => { await renderAndOpenFilter(PLAIN_COLUMNS, neverLoads); const plain = latestCandidates(); - cleanup(); - - await renderAndOpenFilter(COLUMNS_CARRYING_KEYS, neverLoads); - const carrying = latestCandidates(); - // Lit control: these ARE the columns-only candidates — the declared - // columns, in order, labelled by the column, with no definition behind them. + // These ARE the columns-only candidates: the declared columns, in order, + // labelled by the column, with no definition behind them. expect(plain.map((f: any) => [f.value, f.label, f.type])).toEqual([ ['account_id', 'Account', 'lookup'], ['name', 'Name', 'text'], ]); + expect(plain[0].referenceTo).toBeUndefined(); + }); + + it('before the definition loads, a column carrying the five keys offers the same candidates as a plain one', async () => { + await renderAndOpenFilter(PLAIN_COLUMNS, neverLoads); + const plain = latestCandidates(); + cleanup(); + + await renderAndOpenFilter(COLUMNS_CARRYING_KEYS, neverLoads); + const carrying = latestCandidates(); expect(carrying).toStrictEqual(plain); for (const key of ['referenceTo', 'displayField', 'idField']) { @@ -138,10 +144,10 @@ describe('objectui#7531 — no relational key is read off a list column', () => } }); - it('the relational target arrives with the object definition (lit control: the capture sees `referenceTo` when one exists)', async () => { + it('control: the relational target arrives with the object definition, and the capture sees it', async () => { let deliver!: (def: unknown) => void; const definition = new Promise((resolve) => { deliver = resolve; }); - await renderAndOpenFilter(COLUMNS_CARRYING_KEYS, () => definition); + await renderAndOpenFilter(PLAIN_COLUMNS, () => definition); expect(latestCandidates()[0].referenceTo).toBeUndefined(); @@ -153,7 +159,6 @@ describe('objectui#7531 — no relational key is read off a list column', () => }, }); - // The definition's own target, not the column's `account`. await waitFor(() => expect(latestCandidates()[0].referenceTo).toBe('crm_account')); }); }); From 086e714564cd7693346e55374964530b0d1db23c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 07:56:05 +0000 Subject: [PATCH 3/3] test(plugin-list): type the objectui#7531 pin's captured candidates Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC Co-authored-by: Claude --- ...w.columnsOnlyRelationalReads-7531.test.tsx | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/plugin-list/src/__tests__/ListView.columnsOnlyRelationalReads-7531.test.tsx b/packages/plugin-list/src/__tests__/ListView.columnsOnlyRelationalReads-7531.test.tsx index 9adfeccf4e..d9d377b11e 100644 --- a/packages/plugin-list/src/__tests__/ListView.columnsOnlyRelationalReads-7531.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.columnsOnlyRelationalReads-7531.test.tsx @@ -27,17 +27,28 @@ import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/re import '@testing-library/jest-dom'; import React from 'react'; import { ListColumnSchema } from '@objectstack/spec/ui'; -import type { ListViewSchema } from '@object-ui/types'; +import type { DataSource, ListViewSchema } from '@object-ui/types'; import { SchemaRendererProvider } from '@object-ui/react'; +/** One filter candidate as ListView builds it. */ +type Candidate = { + value: string; + label: string; + type: string; + options?: unknown; + referenceTo?: string; + displayField?: string; + idField?: string; +}; + // What ListView hands the filter builder: the candidate list itself, rather // than whichever value picker the builder happens to draw from it. -const captured = vi.hoisted(() => ({ fields: [] as any[][] })); +const captured = vi.hoisted(() => ({ fields: [] as Candidate[][] })); vi.mock('@object-ui/components', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - FilterBuilder: (props: { fields: any[] }) => { + FilterBuilder: (props: { fields: Candidate[] }) => { captured.fields.push(props.fields); return null; }, @@ -86,8 +97,8 @@ async function renderAndOpenFilter(columns: unknown[], getObjectSchema: () => Pr columns, } as unknown as ListViewSchema; render( - - + + , ); await waitFor(() => expect(dataSource.getObjectSchema).toHaveBeenCalled()); @@ -123,7 +134,7 @@ describe('objectui#7531 — no relational key is read off a list column', () => // These ARE the columns-only candidates: the declared columns, in order, // labelled by the column, with no definition behind them. - expect(plain.map((f: any) => [f.value, f.label, f.type])).toEqual([ + expect(plain.map((f) => [f.value, f.label, f.type])).toEqual([ ['account_id', 'Account', 'lookup'], ['name', 'Name', 'text'], ]); @@ -139,7 +150,7 @@ describe('objectui#7531 — no relational key is read off a list column', () => const carrying = latestCandidates(); expect(carrying).toStrictEqual(plain); - for (const key of ['referenceTo', 'displayField', 'idField']) { + for (const key of ['referenceTo', 'displayField', 'idField'] as const) { expect(carrying[0][key], key).toBeUndefined(); } });