From 0814660edbcba0108ddc33dc44d294c2cf10c611 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 12:05:21 +0000 Subject: [PATCH 1/2] fix(plugin-list): filter candidates read the declared displayField, and no id column (objectui#10545) The definition-branch filter candidate in ListView and resolveFields in UserFilters read a lookup's display field as display_field, then reference_field, spellings FieldSchema refuses, and never displayField, the one it declares. Both now read displayField alone, the single spelling plugin-grid's copy set reads (objectui#7155). Neither reads id_field any more: FieldSchema declares no id column for a lookup (it refuses idField and id_field alike), so the value picker keys the lookup by its id default. The objectui#7642 census KEEP comments at both sites are replaced by what the code reads now and why. Claude-Session: https://claude.ai/code/session_01KUxVUa7e39aNjhkKi1gsoy Co-authored-by: Claude --- .../10545-filter-candidates-displayfield.md | 31 ++++ packages/plugin-list/src/ListView.tsx | 27 ++- packages/plugin-list/src/UserFilters.tsx | 26 +-- ...stView.declaredDisplayField-10545.test.tsx | 154 ++++++++++++++++++ ...ilters.declaredDisplayField-10545.test.tsx | 103 ++++++++++++ 5 files changed, 322 insertions(+), 19 deletions(-) create mode 100644 .changeset/10545-filter-candidates-displayfield.md create mode 100644 packages/plugin-list/src/__tests__/ListView.declaredDisplayField-10545.test.tsx create mode 100644 packages/plugin-list/src/__tests__/UserFilters.declaredDisplayField-10545.test.tsx diff --git a/.changeset/10545-filter-candidates-displayfield.md b/.changeset/10545-filter-candidates-displayfield.md new file mode 100644 index 0000000000..c7e03e21c8 --- /dev/null +++ b/.changeset/10545-filter-candidates-displayfield.md @@ -0,0 +1,31 @@ +--- +'@object-ui/plugin-list': patch +--- + +fix(plugin-list): list filters label a lookup's values by the `displayField` the object definition declares (objectui#10545) + +Once a list view's object definition loads, the filter builder's candidates and +the toolbar's lookup filter read each lookup field's display field off that +definition. They read `display_field`, then `reference_field`, and never +`displayField`, the only one of the three `@objectstack/spec`'s `FieldSchema` +declares. So a spec-compliant lookup's display field never reached the value +picker, which fell back to its default, `name`. + +Both now read `displayField`, and only it. This is the same single spelling +`@object-ui/plugin-grid` reads off a definition. `FieldSchema` refuses the other +two: `display_field` with a rename to `displayField`, and `reference_field` with +a pointer to `referenceVia`. + +Behaviour change: a field definition that carries only the refused +`display_field` no longer sets the picker's display field, and neither does one +that carries only `reference_field`. The picker falls back to `name`. A stored +definition served through the ObjectStack adapter still works, because that +adapter folds `display_field` onto `displayField` when the schema is loaded. +A definition that reaches the list any other way, such as a host `DataSource` +or an `objectDef` passed straight to `UserFilters`, needs `displayField`. + +The two readers also stop reading `id_field` as the lookup's id column. +`FieldSchema` declares no id column for a lookup and refuses both `id_field` and +`idField`, so the picker now always keys a lookup by `id`, its default. A +definition that carried `id_field` used to make the picker match and emit that +column's values instead. diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 03798ad2b3..fb7f8832f0 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -3629,7 +3629,7 @@ export const ListView = React.forwardRef(({ * The two narrowings now sit downstream of it, one per builder. */ const candidateFields = React.useMemo(() => { - let fields: Array<{ value: string; label: string; type: string; options?: any; referenceTo?: string; displayField?: string; idField?: string }>; + let fields: Array<{ value: string; label: string; type: string; options?: any; referenceTo?: string; displayField?: string }>; // Translate select-field option labels through the i18n resolver. // fieldDef.options may be an array of { value, label } or a keyed object; @@ -3689,13 +3689,24 @@ export const ListView = React.forwardRef(({ // with no rename hint. That is a different question and is filed, not // answered here. referenceTo: field.reference, - // objectui#7642 CENSUS — verdict KEEP. Bag traced: `objectDef` is - // `dataSource.getObjectSchema(schema.objectName)`, so this IS the - // object-schema def. But the serve path runs no parse, so a stored - // pre-strict def still arrives; and there is no camel leg here, so - // retiring these reads deletes the only read of the value. - displayField: field.display_field || field.reference_field, - idField: field.id_field, + // objectui#10545 — the display field is read in the DECLARED spelling + // and only in it. `objectDef` is + // `dataSource.getObjectSchema(schema.objectName)`, the object-schema + // def, and `FieldSchema` declares `displayField`; it refuses + // `display_field` (renaming it to `displayField`) and + // `reference_field` (pointing at `referenceVia`, a different key) + // with `unrecognized_keys`. This is the single spelling `plugin-grid`'s + // copy set reads (`RELATIONAL_META_READ_SET`, objectui#7155), and it + // supersedes the objectui#7642 census KEEP, which held only while + // this chain had no `displayField` leg. A stored pre-strict + // `display_field` is folded onto `displayField` once, at ingestion + // (`normalizeSchemaReferenceKeys`, objectui#7650), never here. + // + // No id column is read: `FieldSchema` declares none for a lookup (it + // refuses `idField` and `id_field` alike), so the filter's value + // picker keys the lookup by its own `id` default, and `plugin-grid`'s + // copy set copies no id column either. + displayField: field.displayField, })); } diff --git a/packages/plugin-list/src/UserFilters.tsx b/packages/plugin-list/src/UserFilters.tsx index e68664f6ff..28faf6b9fa 100644 --- a/packages/plugin-list/src/UserFilters.tsx +++ b/packages/plugin-list/src/UserFilters.tsx @@ -47,8 +47,6 @@ interface ResolvedField { referenceTo?: string; /** Lookup-like fields: display field on referenced object */ displayField?: string; - /** Lookup-like fields: id field on referenced object */ - idField?: string; } /** @@ -272,7 +270,6 @@ function resolveFields( let resolvedType: string | undefined = f.type; let referenceTo: string | undefined; let displayField: string | undefined; - let idField: string | undefined; // Object-level field label from objectDef, used as a fallback when the // view author didn't supply `f.label` (or it was stripped during compile). // Without this the chip degrades to the raw snake_case field key. @@ -297,12 +294,21 @@ function resolveFields( // legacy-only def is canonicalised ONCE at the ingestion choke point // (`normalizeSchemaReferenceKeys`, which warns in dev) — never here. referenceTo = fieldDef.reference; - // objectui#7642 CENSUS — verdict KEEP. `objectDef` is a PUBLIC prop typed - // `any` on a publicly exported component, so the bag cannot be traced past - // this package: the in-repo caller (`ListView`) passes `getObjectSchema` - // output, but an external host's is unknown. No camel leg here either. - displayField = fieldDef.display_field ?? fieldDef.reference_field; - idField = fieldDef.id_field; + // objectui#10545 — the display field is read in the spelling + // `FieldSchema` DECLARES, `displayField`, and only in it; `FieldSchema` + // refuses `display_field` (renaming it to `displayField`) and + // `reference_field` (pointing at `referenceVia`) with + // `unrecognized_keys`. The in-repo caller (`ListView`) passes + // `getObjectSchema` output, and a host's `objectDef` is held to the same + // contract (AGENTS.md #0.1): this matches `ListView`'s own candidate and + // `plugin-grid`'s copy set (`RELATIONAL_META_READ_SET`, objectui#7155), + // and supersedes the objectui#7642 census KEEP, which held only while + // this chain had no `displayField` leg. + // + // No id column is read: `FieldSchema` declares none for a lookup (it + // refuses `idField` and `id_field` alike), so `LookupValuePicker` keys + // the lookup by its own `id` default. + displayField = fieldDef.displayField; if (options.length === 0 && fieldDef.options) { if (Array.isArray(fieldDef.options)) { @@ -364,7 +370,6 @@ function resolveFields( options, referenceTo, displayField, - idField, }; }); } @@ -599,7 +604,6 @@ function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible, type: f.type, referenceTo: f.referenceTo, displayField: f.displayField, - idField: f.idField, }} value={selected} multiple={true} diff --git a/packages/plugin-list/src/__tests__/ListView.declaredDisplayField-10545.test.tsx b/packages/plugin-list/src/__tests__/ListView.declaredDisplayField-10545.test.tsx new file mode 100644 index 0000000000..50ae721b36 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.declaredDisplayField-10545.test.tsx @@ -0,0 +1,154 @@ +/** + * 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#10545 — the filter candidate reads a lookup's display field in the + * spelling `FieldSchema` declares, and only in it. + * + * Once the object definition loads, `ListView` builds its filter and sort + * candidates from `objectDef.fields`. That branch used to read + * `display_field || reference_field` and `id_field`, spellings `FieldSchema` + * refuses, and never `displayField`, the one it declares. So a spec-compliant + * lookup's display field never reached the filter's value picker. The ruling + * (triage 5831112408): read the declared `displayField`, with no fallback to + * the refused spellings, the way `plugin-grid`'s copy set reads it. + * + * Every render here lets the definition load: the columns-only branch before + * it is objectui#7531's pin, and a test that never loaded the definition would + * read that 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 { FieldSchema } from '@objectstack/spec/data'; +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 Candidate[][] })); +vi.mock('@object-ui/components', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + FilterBuilder: (props: { fields: Candidate[] }) => { + captured.fields.push(props.fields); + return null; + }, + }; +}); + +import { ListView } from '../ListView'; + +/** The lookup def with no display spelling at all. */ +const BARE_LOOKUP = { type: 'lookup', label: 'Account', reference: 'crm_account' } as const; + +/** The spec-compliant def: the declared spelling, and no snake twin. */ +const DECLARED_LOOKUP = { ...BARE_LOOKUP, displayField: 'full_name' } as const; + +/** The spellings the candidate used to read, each refused by `FieldSchema`. */ +const REFUSED_SPELLINGS = { + display_field: 'full_name', + reference_field: 'full_name', + id_field: 'code', +} as const; + +function makeDataSource(accountDef: Record) { + return { + 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_contact', + fields: { + account_id: accountDef, + name: { type: 'text', label: 'Name' }, + }, + }), + }; +} + +/** Render, open the filter builder, and wait for the DEFINITION branch. */ +async function lookupCandidateFor(accountDef: Record): Promise { + captured.fields.length = 0; + const dataSource = makeDataSource(accountDef); + const schema = { + type: 'list-view', + objectName: 'crm_contact', + viewType: 'grid', + columns: [{ field: 'account_id', label: 'Account', type: 'lookup' }, { field: 'name', label: 'Name' }], + } as unknown as ListViewSchema; + render( + + + , + ); + fireEvent.click(screen.getByRole('button', { name: /^filter/i })); + await screen.findByText('Filter Records'); + // `referenceTo` is read off the definition only (objectui#7531), so seeing + // it proves the candidate below came from the `objectDef.fields` branch. + await waitFor(() => { + const latest = captured.fields[captured.fields.length - 1]; + expect(latest?.find((f) => f.value === 'account_id')?.referenceTo).toBe('crm_account'); + }); + const latest = captured.fields[captured.fields.length - 1]; + return latest.find((f) => f.value === 'account_id') as Candidate; +} + +afterEach(() => { + cleanup(); + captured.fields.length = 0; +}); + +describe('objectui#10545 — the definition-branch candidate reads the declared `displayField`', () => { + it('the spec accepts the declared def and refuses every retired spelling (lit control: the bare def parses)', () => { + expect(FieldSchema.safeParse({ name: 'account_id', ...BARE_LOOKUP }).success).toBe(true); + expect(FieldSchema.safeParse({ name: 'account_id', ...DECLARED_LOOKUP }).success).toBe(true); + for (const [key, value] of Object.entries({ ...REFUSED_SPELLINGS, idField: 'code' })) { + const result = FieldSchema.safeParse({ name: 'account_id', ...BARE_LOOKUP, [key]: value }); + expect(result.success, key).toBe(false); + const issue = result.error?.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue && 'keys' in issue ? issue.keys : [], key).toEqual([key]); + } + }); + + it('a lookup declaring `displayField` hands that display field to the candidate', async () => { + const candidate = await lookupCandidateFor(DECLARED_LOOKUP); + expect(candidate.displayField).toBe('full_name'); + }); + + it('a def carrying only `display_field` no longer sets it', async () => { + const candidate = await lookupCandidateFor({ ...BARE_LOOKUP, display_field: REFUSED_SPELLINGS.display_field }); + expect(candidate.displayField).toBeUndefined(); + }); + + it('a def carrying only `reference_field` no longer sets it', async () => { + const candidate = await lookupCandidateFor({ ...BARE_LOOKUP, reference_field: REFUSED_SPELLINGS.reference_field }); + expect(candidate.displayField).toBeUndefined(); + }); + + it('no id column is read off the def: `id_field` no longer reaches the candidate', async () => { + const candidate = await lookupCandidateFor({ ...DECLARED_LOOKUP, id_field: REFUSED_SPELLINGS.id_field }); + expect(candidate.idField).toBeUndefined(); + expect(candidate.displayField).toBe('full_name'); + }); +}); diff --git a/packages/plugin-list/src/__tests__/UserFilters.declaredDisplayField-10545.test.tsx b/packages/plugin-list/src/__tests__/UserFilters.declaredDisplayField-10545.test.tsx new file mode 100644 index 0000000000..d581526a1b --- /dev/null +++ b/packages/plugin-list/src/__tests__/UserFilters.declaredDisplayField-10545.test.tsx @@ -0,0 +1,103 @@ +/** + * 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#10545 — the toolbar's lookup filter labels its values by the display + * field the object definition DECLARES. + * + * `resolveFields` used to read `display_field ?? reference_field` and + * `id_field` off the object-schema def, spellings `FieldSchema` refuses, and + * never `displayField`, the one it declares. The value picker then fell back to + * `name`, so a spec-compliant lookup's chip showed the wrong label. The ruling + * (triage 5831112408): read the declared `displayField`, with no fallback to + * the refused spellings. + * + * The measurement is the picker's own label resolution: a restored selection + * is resolved by one `dataSource.find` on the referenced object, and the trigger + * shows the label that came back. The record carries a DIFFERENT value under + * every field the picker could read, so the label names the field it used. + */ +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react'; +import type { DataSource } from '@object-ui/types'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { UserFilters } from '../UserFilters'; + +/** The referenced record: one distinct value per field the picker could read. */ +const ACCOUNT = { id: 'a1', code: 'ACME', full_name: 'Ada Lovelace', name: 'ada' }; + +const BARE_LOOKUP = { type: 'lookup', label: 'Account', reference: 'crm_account' } as const; + +function renderLookupFilter(accountDef: Record) { + const dataSource = { + find: vi.fn().mockResolvedValue({ data: [ACCOUNT], total: 1 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(), + }; + render( + + {}} + initialSelections={{ account_id: [ACCOUNT.id] }} + /> + , + ); + fireEvent.click(screen.getByTestId('filter-badge-account_id')); + // Non-vacuity: the remote lookup picker is what rendered, not a plain list. + expect(screen.getByTestId('filter-lookup-account_id')).toBeTruthy(); + return dataSource; +} + +/** The label the picker's trigger shows once the selection has resolved. */ +async function resolvedLabel(dataSource: { find: ReturnType }): Promise { + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); + const trigger = screen.getByTestId('lookup-picker-account_id'); + // Before resolution the trigger shows the raw id; wait until it does not. + await waitFor(() => expect(trigger.textContent).not.toBe(ACCOUNT.id)); + return trigger.textContent ?? ''; +} + +afterEach(() => { + cleanup(); +}); + +describe('objectui#10545 — `UserFilters` lookup picker reads the declared `displayField`', () => { + it('control: a def declaring no display field labels the value by the picker default, `name`', async () => { + const dataSource = renderLookupFilter(BARE_LOOKUP); + expect(await resolvedLabel(dataSource)).toBe(ACCOUNT.name); + }); + + it('a lookup declaring `displayField` resolves its label by that field', async () => { + const dataSource = renderLookupFilter({ ...BARE_LOOKUP, displayField: 'full_name' }); + expect(await resolvedLabel(dataSource)).toBe(ACCOUNT.full_name); + }); + + it('a def carrying only `display_field` no longer sets the display field', async () => { + const dataSource = renderLookupFilter({ ...BARE_LOOKUP, display_field: 'full_name' }); + expect(await resolvedLabel(dataSource)).toBe(ACCOUNT.name); + }); + + it('a def carrying only `reference_field` no longer sets the display field', async () => { + const dataSource = renderLookupFilter({ ...BARE_LOOKUP, reference_field: 'full_name' }); + expect(await resolvedLabel(dataSource)).toBe(ACCOUNT.name); + }); + + it('no id column is read off the def: `id_field` no longer re-keys the lookup, identity stays `id`', async () => { + const dataSource = renderLookupFilter({ ...BARE_LOOKUP, displayField: 'full_name', id_field: 'code' }); + expect(await resolvedLabel(dataSource)).toBe(ACCOUNT.full_name); + const [, params] = dataSource.find.mock.calls[0] as [string, { $filter: Record }]; + expect(Object.keys(params.$filter)).toEqual(['id']); + }); +}); From 61ce977984278abec41e2cffc9ab36e5da9aef60 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 12:20:36 +0000 Subject: [PATCH 2/2] fix(plugin-list): the columns-only filter fallback reads no options off a list column (objectui#10547) Before the object definition loads, ListView builds its filter and sort candidates from the declared columns. That fallback read `options` off each column, a key ListColumnSchema refuses with unrecognized_keys. Reusing the objectui#7531 ruling (remove), the read is dropped: a select field's options reach the filter builder from the object definition once it loads. Claude-Session: https://claude.ai/code/session_01KUxVUa7e39aNjhkKi1gsoy Co-authored-by: Claude --- .../10545-filter-candidates-displayfield.md | 7 + packages/plugin-list/src/ListView.tsx | 2 +- ...ListView.columnsOnlyOptions-10547.test.tsx | 157 ++++++++++++++++++ 3 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 packages/plugin-list/src/__tests__/ListView.columnsOnlyOptions-10547.test.tsx diff --git a/.changeset/10545-filter-candidates-displayfield.md b/.changeset/10545-filter-candidates-displayfield.md index c7e03e21c8..2bd687c226 100644 --- a/.changeset/10545-filter-candidates-displayfield.md +++ b/.changeset/10545-filter-candidates-displayfield.md @@ -29,3 +29,10 @@ The two readers also stop reading `id_field` as the lookup's id column. `idField`, so the picker now always keys a lookup by `id`, its default. A definition that carried `id_field` used to make the picker match and emit that column's values instead. + +Also (objectui#10547): before a list view's object definition loads, the filter +builder's candidates come from the view's declared `columns`, and that fallback +no longer reads `options` off a list column. `ListColumnSchema` refuses the key +with `unrecognized_keys`, so a spec-compliant view never carries it. A select +field's options now reach the filter builder only from the object definition, +once it loads, which is where they already came from after the load. diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index e942ccaf44..451b85295e 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -3700,8 +3700,8 @@ export const ListView = React.forwardRef(({ // reading it here would resurrect the second spelling. label: tFieldLabel(fieldName, f.label || fieldName), type: f.type || 'text', - options: buildOptions(fieldName, f.options), // objectui#7531 (ruled): a list column declares no relational target; it comes from the object definition once loaded. + // objectui#10547 (same ruling): nor select options — `ListColumnSchema` refuses `options` with `unrecognized_keys`; they come from the object definition once loaded. }]; }); } else { diff --git a/packages/plugin-list/src/__tests__/ListView.columnsOnlyOptions-10547.test.tsx b/packages/plugin-list/src/__tests__/ListView.columnsOnlyOptions-10547.test.tsx new file mode 100644 index 0000000000..2f0da8572d --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.columnsOnlyOptions-10547.test.tsx @@ -0,0 +1,157 @@ +/** + * 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#10547 — the columns-only candidate fallback reads no select options + * 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 + * `options` off each column, and `ListColumnSchema` refuses the key with + * `unrecognized_keys` and no rename hint, so a spec-compliant producer can never + * put it there. The ruling objectui#7531 made for the relational keys + * (5809501335, letter "remove") is reused for this one: select options come from + * the object definition once it loads, never from a column. + * + * The regression window is the pre-load one, so the pin holds `getObjectSchema` + * pending. The lit control then lets the definition load and sees the options + * arrive from it. + */ +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 { 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?: Array<{ value: unknown; label: string }>; +}; + +// What ListView hands the filter builder: the candidate list itself. +const captured = vi.hoisted(() => ({ fields: [] as Candidate[][] })); +vi.mock('@object-ui/components', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + FilterBuilder: (props: { fields: Candidate[] }) => { + captured.fields.push(props.fields); + return null; + }, + }; +}); + +import { ListView } from '../ListView'; + +const STATUS_OPTIONS = [ + { value: 'open', label: 'Open' }, + { value: 'closed', label: 'Closed' }, +]; + +const PLAIN_COLUMNS = [ + { field: 'status', label: 'Status', type: 'select' }, + { field: 'name', label: 'Name' }, +]; + +const COLUMNS_CARRYING_OPTIONS = [{ ...PLAIN_COLUMNS[0], options: STATUS_OPTIONS }, 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_ticket', + 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)); +} + +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#10547 — no select options are read off a list column', () => { + it('the spec refuses `options` on a column (lit control: the bare column parses)', () => { + expect(ListColumnSchema.safeParse(PLAIN_COLUMNS[0]).success).toBe(true); + const result = ListColumnSchema.safeParse(COLUMNS_CARRYING_OPTIONS[0]); + expect(result.success).toBe(false); + const issue = result.error?.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue && 'keys' in issue ? issue.keys : []).toEqual(['options']); + }); + + it('before the definition loads, a column carrying `options` offers the same candidates as a plain one', async () => { + await renderAndOpenFilter(PLAIN_COLUMNS, neverLoads); + const plain = latestCandidates(); + cleanup(); + + await renderAndOpenFilter(COLUMNS_CARRYING_OPTIONS, neverLoads); + const carrying = latestCandidates(); + + // These ARE the columns-only candidates: the declared columns, in order. + expect(carrying.map((f) => [f.value, f.label, f.type])).toEqual([ + ['status', 'Status', 'select'], + ['name', 'Name', 'text'], + ]); + expect(carrying[0].options).toBeUndefined(); + expect(carrying).toStrictEqual(plain); + }); + + it('control: the select options arrive with the object definition, and the capture sees them', async () => { + let deliver!: (def: unknown) => void; + const definition = new Promise((resolve) => { deliver = resolve; }); + await renderAndOpenFilter(PLAIN_COLUMNS, () => definition); + + expect(latestCandidates()[0].options).toBeUndefined(); + + deliver({ + name: 'crm_ticket', + fields: { + status: { type: 'select', label: 'Status', options: STATUS_OPTIONS }, + name: { type: 'text', label: 'Name' }, + }, + }); + + await waitFor(() => + expect(latestCandidates().find((f) => f.value === 'status')?.options?.map((o) => o.value)).toEqual([ + 'open', + 'closed', + ]), + ); + }); +});