From 5d7504412ecea3e1edf29552e0192097708931f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:50:28 +0000 Subject: [PATCH 1/3] fix(plugin-list): coerce a restored quick-filter value once a late definition types its field (objectui#12008) A dropdown field declared without its type takes its type and options from the object definition, which loads after the list mounts. The bar coerced URL-restored strings to the option value types only at mount or on a field's arrival, so a restored 'true' stayed a string: no box ticked and the query filtered on "true". The arrival effect becomes one settle effect keyed on the field names and each field's typing (resolved type plus option values). The first time a field's typing moves, its still-starting value is coerced once more; a value the user changed or cleared is left alone, and nothing is emitted when the coercion moves nothing. The new suite enumerates every read the bar makes of the definition (recorded through a Proxy) and probes each against a late definition. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .changeset/12008-late-type-coercion.md | 13 + packages/plugin-list/src/UserFilters.tsx | 72 ++- ...serFilters.lateTypeCoercion-12008.test.tsx | 425 ++++++++++++++++++ 3 files changed, 494 insertions(+), 16 deletions(-) create mode 100644 .changeset/12008-late-type-coercion.md create mode 100644 packages/plugin-list/src/__tests__/UserFilters.lateTypeCoercion-12008.test.tsx diff --git a/.changeset/12008-late-type-coercion.md b/.changeset/12008-late-type-coercion.md new file mode 100644 index 0000000000..6de1c94c68 --- /dev/null +++ b/.changeset/12008-late-type-coercion.md @@ -0,0 +1,13 @@ +--- +'@object-ui/plugin-list': patch +--- + +A quick-filter value restored from the URL now gets its field's type once the object definition loads, when the field is declared without its type (objectui#12008). + +Values restored from the URL arrive as strings. The dropdown bar converts them to the option's value type: `'true'` to `true` for a boolean field, `'2'` to `2` for a numeric option. It did this once, at mount. A field declared without its type, such as `fields: [{ field: 'is_active' }]`, takes its type and options from the object definition, and that definition loads after the list mounts. So the bar converted the value before it knew the type. A shared link such as `?uf_is_active=true` then filtered on the string `"true"`, and the chip counted 1 with no box ticked. + +Now, the first time the definition changes a field's type or options, the bar converts that field's starting value once more: the restored value, or the author's default. The box is ticked and the query carries the typed value. A value the user has changed or cleared in the meantime is left as it is. A field declared with its type behaves as before. Nothing is emitted when the conversion changes nothing, so a select field with string options issues no extra query. + +The fix also lists every read the bar makes of the object definition and tests each one against a definition that loads late. The label, the i18n scope and the lookup picker already followed a late definition; the starting value's type was the only read that did not. The `tabs` mode and the deprecated `toggle` mode read nothing from the definition. + +No export, prop, type or language-pack key changes. diff --git a/packages/plugin-list/src/UserFilters.tsx b/packages/plugin-list/src/UserFilters.tsx index 06022371d7..bbf849fb2c 100644 --- a/packages/plugin-list/src/UserFilters.tsx +++ b/packages/plugin-list/src/UserFilters.tsx @@ -446,6 +446,15 @@ function coerceToOptionTypes(selection: DropdownSelection, resolvedFields: Resol return changed ? coerced : selection; } +/** + * What `coerceToOptionTypes` reads of a field: its resolved type and its option + * values. A field declared without its type gets both from the object + * definition, so they move when that definition loads (objectui#12008). + */ +function typingOf(field: ResolvedField): string { + return JSON.stringify([field.type, field.options.map(o => o.value)]); +} + function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible, className, initialSelections, onSelectionsChange }: DropdownFiltersProps) { const { fieldLabel, translateOptions } = useSafeFieldLabel(); const moreLabel = useMoreLabel(); @@ -474,6 +483,11 @@ function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible, settledFieldsRef.current = new Set(fields.map(f => f.field)); } + // Per field name, the typing (`typingOf`) its starting selection was last + // coerced against, or `null` once that value is final: coerced again after + // the definition moved the typing (objectui#12008), or changed by the user. + const typingRef = React.useRef(new Map()); + // Option counts must reflect the result set BEFORE the field's own // selection narrows it — the server returns already-filtered rows, so // counting those would zero out every unselected option the moment one @@ -529,6 +543,7 @@ function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible, ); const handleChange = (field: string, values: (string | number | boolean)[]) => { + typingRef.current.set(field, null); const next = { ...selectedValues, [field]: values }; setSelectedValues(next); emitFilters(next); @@ -546,33 +561,58 @@ function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible, // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // A field that appears AFTER mount gets the starting selection the mount - // would have given it, once (objectui#12001). That is how a URL-restored - // selection reaches chips derived from an object definition that loaded - // after the list mounted: the chip shows it and the query carries it. Like - // the mount emit above, it reports through `onFilterChange` only; the + // A starting selection is settled after mount in two steps, each at most + // once per field: + // + // - ARRIVAL (objectui#12001). A field that appears AFTER mount gets the + // starting selection the mount would have given it. That is how a + // URL-restored selection reaches chips derived from an object definition + // that loaded after the list mounted: the chip shows it and the query + // carries it. A list with declared fields settles them all at mount, so + // this adopts nothing there unless the declared list itself grows. + // - TYPING (objectui#12008). A starting value is coerced against its field's + // typing at that moment. A field declared without its type takes its type + // and options from the definition, which loads after the list mounts, so + // a restored 'true' stayed a string: no box ticked and the query filtered + // on the string. The first time a field's typing moves, its value is + // coerced once more, unless the user has changed it since. A field typed + // at mount keeps its typing, so it is not coerced again. + // + // Like the mount emit above, both report through `onFilterChange` only; the // selection came from the host, so nothing is echoed back through - // `onSelectionsChange`. A list with declared fields settles them all at - // mount, so this adopts nothing there unless the declared list itself grows. + // `onSelectionsChange`. One effect runs both steps, so an arrival and a late + // typing in the same commit reach the same emit. // - // Keyed on the field NAMES, a primitive, not on the `fields` array: the - // host rebuilds that array on every definition render (AGENTS.md #10). + // Keyed on primitives, not on the `fields` array, which the host rebuilds + // on every definition render, nor on the `resolvedFields` memo's identity + // (AGENTS.md #10). const fieldNamesKey = JSON.stringify(fields.map(f => f.field)); + const typingKey = JSON.stringify(resolvedFields.map(typingOf)); React.useEffect(() => { const settled = settledFieldsRef.current!; + const typing = typingRef.current; const arrived = fields.filter(f => !settled.has(f.field)); - if (arrived.length === 0) return; arrived.forEach(f => settled.add(f.field)); - const adopted = coerceToOptionTypes( - startingSelections(arrived, initialSelections, controlKinds), - resolvedFields, - ); - if (Object.keys(adopted).length === 0) return; + const retyped: DropdownSelection = {}; + resolvedFields.forEach(f => { + const was = typing.get(f.field); + // First sight: the typing the mount effect, or the arrival below, has + // just coerced this field's starting value against. + if (was === undefined) typing.set(f.field, typingOf(f)); + else if (was !== null && was !== typingOf(f)) { + typing.set(f.field, null); + if (selectedValues[f.field]) retyped[f.field] = selectedValues[f.field]; + } + }); + const start = startingSelections(arrived, initialSelections, controlKinds); + const due = { ...retyped, ...start }; + const adopted = coerceToOptionTypes(due, resolvedFields); + if (adopted === due && Object.keys(start).length === 0) return; const next = { ...selectedValues, ...adopted }; setSelectedValues(next); emitFilters(next); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fieldNamesKey]); + }, [fieldNamesKey, typingKey]); // Split fields into visible and overflow based on maxVisible const visibleFields = maxVisible !== undefined && maxVisible < resolvedFields.length diff --git a/packages/plugin-list/src/__tests__/UserFilters.lateTypeCoercion-12008.test.tsx b/packages/plugin-list/src/__tests__/UserFilters.lateTypeCoercion-12008.test.tsx new file mode 100644 index 0000000000..2a4a457b6e --- /dev/null +++ b/packages/plugin-list/src/__tests__/UserFilters.lateTypeCoercion-12008.test.tsx @@ -0,0 +1,425 @@ +/** + * 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#12008 — a URL-restored quick-filter value on a declared field that + * only the object definition types must be coerced once the definition loads. + * + * THE DEFECT. URL-restored values arrive as strings. `DropdownFilters` coerces + * them to the field's resolved option types (`coerceToOptionTypes`) once: at + * mount, or when the field first appears (objectui#12001). A field declared + * WITHOUT its type (`fields: [{ field: 'is_active' }]`) takes its type and + * options from the object definition, which `ListView` fetches after it + * mounts. The coercion therefore ran against an untyped field, and nothing + * coerced again when the definition arrived: the query filtered on the string + * "true", and no option was ticked while the chip counted 1. + * + * THE ENUMERATION (triage's closing pass for this family). The second block + * records every read `UserFilters` makes of the object definition, through a + * Proxy, and pins that set to `DEFINITION_READS`. Each enumerated read has a + * LATE probe: the bar mounts with no definition, the definition arrives, and + * the state the read feeds must follow it. A new read of the definition turns + * COMPLETENESS red until it is enumerated, and `LATE`'s type then demands its + * probe. The sibling modes, `tabs` and the deprecated `toggle`, read nothing + * from the definition. + * + * WHY THE DEFINITION IS LATE. In the list-level block, `getObjectSchema` waits + * on a promise this file releases by hand, and each case first asserts that no + * `find` has gone out, which `ListView` holds until the definition settles. In + * the bar-level block, the bar mounts with `objectDef` undefined and the + * definition is handed in by a rerender. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, cleanup, act, within } from '@testing-library/react'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { ListView } from '../ListView'; +import { UserFilters } from '../UserFilters'; + +const ACCOUNT = { id: 'a1', name: 'ada', full_name: 'Ada Lovelace' }; + +const objectDef = { + name: 'ticket', + label: 'Ticket', + fields: { + id: { name: 'id', type: 'text' }, + subject: { name: 'subject', type: 'text', label: 'Subject' }, + is_active: { name: 'is_active', type: 'boolean', label: 'Active' }, + is_vip: { name: 'is_vip', type: 'boolean', label: 'VIP' }, + // Numeric option values, as the existing mount-time pin in + // `UserFilters.test.tsx` ("coerces URL-restored string values to typed + // option values") already has them on its definition. + points: { + name: 'points', + type: 'select', + label: 'Points', + options: [ + { value: 1, label: 'One' }, + { value: 2, label: 'Two' }, + ], + }, + status: { + name: 'status', + type: 'select', + label: 'Status', + options: [ + { value: 'open', label: 'Open' }, + { value: 'closed', label: 'Closed' }, + ], + }, + account_id: { name: 'account_id', type: 'lookup', label: 'Account', reference: 'crm_account', displayField: 'full_name' }, + }, +}; + +const rows = [{ id: '1', subject: 'First', is_active: true, points: 2 }]; + +/** A data source whose definition answers only once `release()` is called. */ +function makeGatedDataSource() { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const dataSource = { + find: vi.fn(async () => ({ data: rows, total: rows.length })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => { + await gate; + return objectDef; + }), + } as any; + return { dataSource, release: () => act(async () => release()) }; +} + +function mountList(userFilters: Record, selections: Record) { + const { dataSource, release } = makeGatedDataSource(); + render( + + + , + ); + return { dataSource, release }; +} + +const filters = (dataSource: any) => dataSource.find.mock.calls.map((call: any[]) => call[1]?.$filter); +const lastFilter = (dataSource: any) => filters(dataSource).at(-1); + +/** Opens the chip's popover and reads whether the option labelled `label` is ticked. */ +function ticked(field: string, label: string): boolean { + fireEvent.click(screen.getByTestId(`filter-badge-${field}`)); + const box = within(screen.getByTestId(`filter-options-${field}`)).getByLabelText(label) as HTMLInputElement; + return box.checked; +} + +afterEach(cleanup); + +describe('a restored value on a field typed only by a late definition is coerced (objectui#12008)', () => { + it('UNTYPED BOOLEAN: the True box is ticked and the last find carries ["is_active","=",true]', async () => { + const { dataSource, release } = mountList({ element: 'dropdown', fields: [{ field: 'is_active' }] }, { is_active: ['true'] }); + // Precondition: the declared chip holds the URL string before the + // definition has loaded. + expect(screen.getByTestId('filter-badge-is_active').textContent).toContain('1'); + expect(dataSource.find).not.toHaveBeenCalled(); + await release(); + await waitFor(() => expect(lastFilter(dataSource)).toEqual(['is_active', '=', true])); + expect(ticked('is_active', 'True')).toBe(true); + }); + + it('TYPED CONTROL: the same field declared with its type sends the typed value on every find, as before', async () => { + const { dataSource, release } = mountList( + { element: 'dropdown', fields: [{ field: 'is_active', type: 'boolean' }] }, + { is_active: ['true'] }, + ); + expect(dataSource.find).not.toHaveBeenCalled(); + await release(); + await waitFor(() => expect(lastFilter(dataSource)).toEqual(['is_active', '=', true])); + await new Promise((r) => setTimeout(r, 50)); + expect(new Set(filters(dataSource).map((f: unknown) => JSON.stringify(f)))).toEqual( + new Set([JSON.stringify(['is_active', '=', true])]), + ); + expect(ticked('is_active', 'True')).toBe(true); + }); + + it('NUMERIC OPTION: a restored "2" against the definition option 2 is ticked and the last find carries 2', async () => { + const { dataSource, release } = mountList({ element: 'dropdown', fields: [{ field: 'points' }] }, { points: ['2'] }); + expect(dataSource.find).not.toHaveBeenCalled(); + await release(); + await waitFor(() => expect(lastFilter(dataSource)).toEqual(['points', '=', 2])); + expect(ticked('points', 'Two')).toBe(true); + }); + + it('A USER CLEAR SURVIVES: a value cleared before the definition loads stays cleared after it', async () => { + const { dataSource, release } = mountList({ element: 'dropdown', fields: [{ field: 'is_active' }] }, { is_active: ['true'] }); + fireEvent.click(screen.getByTestId('filter-clear-is_active')); + expect(dataSource.find).not.toHaveBeenCalled(); + await release(); + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); + await new Promise((r) => setTimeout(r, 50)); + expect(screen.queryByTestId('filter-clear-is_active')).toBeNull(); + expect(filters(dataSource).every((f: unknown) => f === undefined)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// The enumeration +// --------------------------------------------------------------------------- + +/** + * Every read the dropdown bar makes of the object definition, and the state it + * feeds. COMPLETENESS pins this list to what the bar actually reads. + */ +const DEFINITION_READS = [ + // The per-field lookup every read below goes through. + 'objectDef.fields', + // The i18n scope of the chip label and the option labels. + 'objectDef.name', + // The resolved type: boolean options, the lookup picker, and the coercion + // of a starting (restored or default) value. + 'fieldDef.type', + // The option list, and the coercion of a starting value. + 'fieldDef.options', + // The chip label when the author gives none. + 'fieldDef.label', + // The lookup picker's target object. + 'fieldDef.reference', + // The lookup picker's display column. + 'fieldDef.displayField', +] as const; + +type DefinitionRead = (typeof DEFINITION_READS)[number]; + +/** Wraps a definition so every string-keyed read of it, and of its field definitions, is recorded. */ +function recording(def: typeof objectDef) { + const reads = new Set(); + const track = (target: T, prefix: string): T => + new Proxy(target, { + get(t, key, receiver) { + if (typeof key === 'string') reads.add(`${prefix}.${key}`); + return Reflect.get(t, key, receiver); + }, + }); + const fields = new Proxy(def.fields, { + get(t, key, receiver) { + const value = Reflect.get(t, key, receiver); + return typeof key === 'string' && value && typeof value === 'object' ? track(value, 'fieldDef') : value; + }, + }); + const proxied = new Proxy(def, { + get(t, key, receiver) { + if (typeof key === 'string') reads.add(`objectDef.${key}`); + return key === 'fields' ? fields : Reflect.get(t, key, receiver); + }, + }); + return { proxied, reads }; +} + +type BarProps = { + fields: Array>; + onFilterChange: (filters: any[]) => void; + initialSelections?: Record; +}; + +/** Mounts the dropdown bar with no definition; `arrive()` hands the definition in. */ +function mountBar({ fields, onFilterChange, initialSelections }: BarProps, wrapper?: React.ComponentType<{ children: React.ReactNode }>) { + const ui = (def: unknown) => ( + + ); + const { rerender } = render(ui(undefined), wrapper ? { wrapper } : undefined); + return { arrive: () => rerender(ui(objectDef)) }; +} + +/** Per enumerated read: the state it feeds follows a definition that arrives after mount. */ +const LATE: Record, () => void | Promise> = { + 'fieldDef.type': () => { + const onFilterChange = vi.fn(); + const { arrive } = mountBar({ + fields: [{ field: 'is_active' }, { field: 'is_vip', defaultValues: ['true'] }], + onFilterChange, + initialSelections: { is_active: ['true'] }, + }); + // Before the definition: the restored value and the author default are + // both still the string. + expect(onFilterChange).toHaveBeenLastCalledWith([ + ['is_active', 'in', ['true']], + ['is_vip', 'in', ['true']], + ]); + arrive(); + expect(onFilterChange).toHaveBeenLastCalledWith([ + ['is_active', 'in', [true]], + ['is_vip', 'in', [true]], + ]); + expect(ticked('is_active', 'True')).toBe(true); + }, + 'fieldDef.options': () => { + const onFilterChange = vi.fn(); + const { arrive } = mountBar({ fields: [{ field: 'points' }], onFilterChange, initialSelections: { points: ['2'] } }); + arrive(); + expect(onFilterChange).toHaveBeenLastCalledWith([['points', 'in', [2]]]); + expect(ticked('points', 'Two')).toBe(true); + }, + 'fieldDef.label': () => { + const { arrive } = mountBar({ fields: [{ field: 'is_active' }], onFilterChange: vi.fn() }); + expect(screen.getByTestId('filter-badge-is_active').textContent).toContain('is_active'); + arrive(); + expect(screen.getByTestId('filter-badge-is_active').textContent).toContain('Active'); + }, + 'objectDef.name': () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + const { arrive } = mountBar({ fields: [{ field: 'is_active' }], onFilterChange: vi.fn() }, wrapper); + arrive(); + expect(screen.getByTestId('filter-badge-is_active').textContent).toContain('Is live'); + }, + 'fieldDef.reference': () => lookupFollowsLateDefinition(), + 'fieldDef.displayField': () => lookupFollowsLateDefinition(), +}; + +/** The account chip becomes the remote picker, labelled by the declared display field. */ +async function lookupFollowsLateDefinition() { + 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(), + } as any; + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + const { arrive } = mountBar( + { fields: [{ field: 'account_id' }], onFilterChange: vi.fn(), initialSelections: { account_id: [ACCOUNT.id] } }, + wrapper, + ); + arrive(); + fireEvent.click(screen.getByTestId('filter-badge-account_id')); + expect(screen.getByTestId('filter-lookup-account_id')).toBeTruthy(); + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); + await waitFor(() => expect(screen.getByTestId('lookup-picker-account_id').textContent).toBe(ACCOUNT.full_name)); +} + +describe('every read UserFilters makes of the object definition, enumerated (objectui#12008)', () => { + it('COMPLETENESS: the dropdown bar reads exactly the enumerated keys of the definition', () => { + const { proxied, reads } = recording(objectDef); + render( + , + ); + expect([...reads].sort()).toEqual([...DEFINITION_READS].sort()); + }); + + it.each(['tabs', 'toggle'] as const)('SIBLINGS: %s mode reads nothing from the definition', (element) => { + const { proxied, reads } = recording(objectDef); + render( + , + ); + expect([...reads]).toEqual([]); + }); + + it.each(Object.keys(LATE) as Array)('LATE %s: the state it feeds follows a definition that arrives after mount', async (read) => { + await LATE[read](); + }); +}); + +describe('the late typing settles once per field and only moves a starting value (objectui#12008)', () => { + it('TYPED AT MOUNT: a declared typed field emits nothing more when the definition arrives', () => { + const onFilterChange = vi.fn(); + const { arrive } = mountBar({ + fields: [{ field: 'is_active', type: 'boolean' }], + onFilterChange, + initialSelections: { is_active: ['true'] }, + }); + expect(onFilterChange).toHaveBeenLastCalledWith([['is_active', 'in', [true]]]); + const settled = onFilterChange.mock.calls.length; + arrive(); + expect(onFilterChange.mock.calls.length).toBe(settled); + }); + + it('NOTHING TO MOVE: string options from the definition leave a restored string as it was, with no new emit', () => { + const onFilterChange = vi.fn(); + const { arrive } = mountBar({ fields: [{ field: 'status' }], onFilterChange, initialSelections: { status: ['open'] } }); + const settled = onFilterChange.mock.calls.length; + arrive(); + expect(onFilterChange.mock.calls.length).toBe(settled); + expect(ticked('status', 'Open')).toBe(true); + }); + + it('A USER CLEAR SURVIVES: the definition arriving after a clear emits nothing and restores nothing', () => { + const onFilterChange = vi.fn(); + const { arrive } = mountBar({ fields: [{ field: 'is_active' }], onFilterChange, initialSelections: { is_active: ['true'] } }); + fireEvent.click(screen.getByTestId('filter-clear-is_active')); + expect(onFilterChange).toHaveBeenLastCalledWith([]); + const settled = onFilterChange.mock.calls.length; + arrive(); + expect(onFilterChange.mock.calls.length).toBe(settled); + expect(screen.queryByTestId('filter-clear-is_active')).toBeNull(); + }); + + it('ONE COMMIT: a field typed late and a field that arrives with the definition both reach the last emit', () => { + const onFilterChange = vi.fn(); + const initialSelections = { is_active: ['true'], points: ['2'] }; + const ui = (fields: Array>, def: unknown) => ( + + ); + const { rerender } = render(ui([{ field: 'is_active' }], undefined)); + rerender(ui([{ field: 'is_active' }, { field: 'points' }], objectDef)); + expect(onFilterChange).toHaveBeenLastCalledWith([ + ['is_active', 'in', [true]], + ['points', 'in', [2]], + ]); + }); +}); From d87114b86dd9106e2fb891f148b604b0472bfbff Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:53:19 +0000 Subject: [PATCH 2/3] test(plugin-list): pin that a value the user picked before a late definition is not coerced (objectui#12008) The user-change guard in the typing settle had no pin that could go red: a cleared value coerces to itself. Authored options let the user pick before the definition types the field, which makes the guard observable. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .../UserFilters.lateTypeCoercion-12008.test.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/plugin-list/src/__tests__/UserFilters.lateTypeCoercion-12008.test.tsx b/packages/plugin-list/src/__tests__/UserFilters.lateTypeCoercion-12008.test.tsx index 2a4a457b6e..7f7cef0731 100644 --- a/packages/plugin-list/src/__tests__/UserFilters.lateTypeCoercion-12008.test.tsx +++ b/packages/plugin-list/src/__tests__/UserFilters.lateTypeCoercion-12008.test.tsx @@ -403,6 +403,22 @@ describe('the late typing settles once per field and only moves a starting value expect(screen.queryByTestId('filter-clear-is_active')).toBeNull(); }); + it('A USER CHOICE SURVIVES: a value picked before the definition loads is not coerced after it', () => { + const onFilterChange = vi.fn(); + // Authored options let the user pick before the definition types the + // field; the definition then makes it boolean. + const { arrive } = mountBar({ + fields: [{ field: 'is_active', options: [{ value: 'true', label: 'Yes' }, { value: 'false', label: 'No' }] }], + onFilterChange, + }); + fireEvent.click(screen.getByTestId('filter-badge-is_active')); + fireEvent.click(within(screen.getByTestId('filter-options-is_active')).getByLabelText('Yes')); + expect(onFilterChange).toHaveBeenLastCalledWith([['is_active', 'in', ['true']]]); + const settled = onFilterChange.mock.calls.length; + arrive(); + expect(onFilterChange.mock.calls.length).toBe(settled); + }); + it('ONE COMMIT: a field typed late and a field that arrives with the definition both reach the last emit', () => { const onFilterChange = vi.fn(); const initialSelections = { is_active: ['true'], points: ['2'] }; From 633f6ddbf507e0d023605c96c7e836dccf2d5373 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 02:02:48 +0000 Subject: [PATCH 3/3] docs(plugin-list): say which fields the late typing leaves alone (objectui#12008) A declared select with no authored options does get its typing moved when the definition's options arrive; the coercion then moves nothing and emits nothing. The comment now says "type and options known at mount". Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- packages/plugin-list/src/UserFilters.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/plugin-list/src/UserFilters.tsx b/packages/plugin-list/src/UserFilters.tsx index bbf849fb2c..abdc43cb03 100644 --- a/packages/plugin-list/src/UserFilters.tsx +++ b/packages/plugin-list/src/UserFilters.tsx @@ -575,8 +575,9 @@ function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible, // and options from the definition, which loads after the list mounts, so // a restored 'true' stayed a string: no box ticked and the query filtered // on the string. The first time a field's typing moves, its value is - // coerced once more, unless the user has changed it since. A field typed - // at mount keeps its typing, so it is not coerced again. + // coerced once more, unless the user has changed it since. A field whose + // type and options are known at mount keeps its typing, so it is not + // coerced again; one coerced to the value it already holds emits nothing. // // Like the mount emit above, both report through `onFilterChange` only; the // selection came from the host, so nothing is echoed back through