From f6c1e4bb5ee2d8b369fddf6d8c01375a69b771a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 20:31:14 +0000 Subject: [PATCH 1/2] fix(plugin-list): adopt a restored quick-filter selection when derived chips arrive (objectui#12001) A list whose dropdown chips derive from the object definition mounts DropdownFilters with no fields, because the definition loads after the list. The restored selection (initialSelections, the host's uf_* URL params) was read only in the useState initializer, so it had nothing to attach to and a shared `?uf_status=open` link opened unfiltered. A field that appears after mount now gets the starting selection the mount would have given it (author default, then the restored value, coerced to the option types, single-choice clamped), exactly once per field name, and the bar emits the conditions through onFilterChange. Settling each name once keeps a later rebuild of the field list from overwriting a value the user has since chosen or cleared. The mount's starting-value and coercion rules move into two helpers both paths call. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- packages/plugin-list/src/UserFilters.tsx | 151 ++++++++---- ...ilters.restoredDerivedChips-12001.test.tsx | 232 ++++++++++++++++++ 2 files changed, 338 insertions(+), 45 deletions(-) create mode 100644 packages/plugin-list/src/__tests__/UserFilters.restoredDerivedChips-12001.test.tsx diff --git a/packages/plugin-list/src/UserFilters.tsx b/packages/plugin-list/src/UserFilters.tsx index 28faf6b9fa..06022371d7 100644 --- a/packages/plugin-list/src/UserFilters.tsx +++ b/packages/plugin-list/src/UserFilters.tsx @@ -388,6 +388,64 @@ interface DropdownFiltersProps { onSelectionsChange?: (selections: Record>) => void; } +/** Selected values per field name, the dropdown bar's whole filter state. */ +type DropdownSelection = Record; + +/** + * The starting selection for `fields`: the author's `defaultValues`, + * overridden by a restored selection (`initialSelections`, e.g. the host's + * `uf_*` URL params). One rule for the fields present at mount and for a + * field that appears later (objectui#12001). + */ +function startingSelections( + fields: DropdownFiltersProps['fields'], + initialSelections: DropdownFiltersProps['initialSelections'], + controlKinds: Map, +): DropdownSelection { + const init: DropdownSelection = {}; + fields.forEach(f => { + if (f.defaultValues && f.defaultValues.length > 0) { + init[f.field] = f.defaultValues; + } + // Restored selections (e.g. from URL params) override author defaults. + const restored = initialSelections?.[f.field]; + if (restored && restored.length > 0) { + init[f.field] = restored; + } + // A single-choice control can never hold more than one value, whatever + // an author default or a hand-edited URL claims. (A `range` holds its + // [from, to] pair; `text` its one query — both self-limit.) + if (controlKinds.get(f.field) === 'single-choice' && (init[f.field]?.length ?? 0) > 1) { + init[f.field] = init[f.field].slice(0, 1); + } + }); + return init; +} + +/** + * URL-restored values arrive as strings; coerce them against the resolved + * option value types so the checkbox state (`selected.includes(opt.value)`) + * matches typed options (numbers, booleans), and the query condition uses the + * same coerced value. Returns `selection` itself when nothing changed. + */ +function coerceToOptionTypes(selection: DropdownSelection, resolvedFields: ResolvedField[]): DropdownSelection { + const coerced: DropdownSelection = {}; + let changed = false; + for (const [field, values] of Object.entries(selection)) { + const rf = resolvedFields.find(f => f.field === field); + const next = values.map(v => { + if (!rf || typeof v !== 'string') return v; + const opt = rf.options.find(o => String(o.value) === v); + if (opt && opt.value !== v) return opt.value; + if (rf.type === 'boolean' && (v === 'true' || v === 'false')) return v === 'true'; + return v; + }); + coerced[field] = next; + if (next.some((v, i) => v !== values[i])) changed = true; + } + return changed ? coerced : selection; +} + function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible, className, initialSelections, onSelectionsChange }: DropdownFiltersProps) { const { fieldLabel, translateOptions } = useSafeFieldLabel(); const moreLabel = useMoreLabel(); @@ -400,28 +458,21 @@ function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible, () => new Map(fields.map(f => [f.field, FILTER_CONTROL_KINDS[f.type ?? '']])), [fields], ); - const [selectedValues, setSelectedValues] = React.useState< - Record - >(() => { - const init: Record = {}; - fields.forEach(f => { - if (f.defaultValues && f.defaultValues.length > 0) { - init[f.field] = f.defaultValues; - } - // Restored selections (e.g. from URL params) override author defaults. - const restored = initialSelections?.[f.field]; - if (restored && restored.length > 0) { - init[f.field] = restored; - } - // A single-choice control can never hold more than one value, whatever - // an author default or a hand-edited URL claims. (A `range` holds its - // [from, to] pair; `text` its one query — both self-limit.) - if (controlKinds.get(f.field) === 'single-choice' && (init[f.field]?.length ?? 0) > 1) { - init[f.field] = init[f.field].slice(0, 1); - } - }); - return init; - }); + const [selectedValues, setSelectedValues] = React.useState( + () => startingSelections(fields, initialSelections, controlKinds), + ); + + // Field names whose starting selection this mount has already settled: the + // fields present at mount (the initializer above) and every field that has + // appeared since (the arrival effect below). A list whose chips derive from + // the object definition mounts with NO fields, because the definition loads + // after it (objectui#12001); settling each name exactly once is what keeps a + // later rebuild of the field list from overwriting a value the user has + // since chosen or cleared. + const settledFieldsRef = React.useRef | null>(null); + if (settledFieldsRef.current === null) { + settledFieldsRef.current = new Set(fields.map(f => f.field)); + } // Option counts must reflect the result set BEFORE the field's own // selection narrows it — the server returns already-filtered rows, so @@ -484,35 +535,45 @@ function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible, onSelectionsChange?.(next); }; - // Emit default/restored filters on mount. URL-restored values arrive as - // strings; coerce them against the resolved option value types so the - // checkbox state (`selected.includes(opt.value)`) matches typed options - // (numbers, booleans) — the query condition uses the same coerced value. + // Emit default/restored filters on mount, coerced to the option value types + // (see `coerceToOptionTypes`) so the checkbox state and the query condition + // read the same typed value. React.useEffect(() => { - let current = selectedValues; - const coerced: Record = {}; - let changed = false; - for (const [field, values] of Object.entries(current)) { - const rf = resolvedFields.find(f => f.field === field); - const next = values.map(v => { - if (!rf || typeof v !== 'string') return v; - const opt = rf.options.find(o => String(o.value) === v); - if (opt && opt.value !== v) return opt.value; - if (rf.type === 'boolean' && (v === 'true' || v === 'false')) return v === 'true'; - return v; - }); - coerced[field] = next; - if (next.some((v, i) => v !== values[i])) changed = true; - } - if (changed) { - setSelectedValues(coerced); - current = coerced; - } + const current = coerceToOptionTypes(selectedValues, resolvedFields); + if (current !== selectedValues) setSelectedValues(current); const hasSelections = Object.values(current).some(v => v.length > 0); if (hasSelections) emitFilters(current); // 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 + // 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. + // + // Keyed on the field NAMES, a primitive, not on the `fields` array: the + // host rebuilds that array on every definition render (AGENTS.md #10). + const fieldNamesKey = JSON.stringify(fields.map(f => f.field)); + React.useEffect(() => { + const settled = settledFieldsRef.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 next = { ...selectedValues, ...adopted }; + setSelectedValues(next); + emitFilters(next); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fieldNamesKey]); + // Split fields into visible and overflow based on maxVisible const visibleFields = maxVisible !== undefined && maxVisible < resolvedFields.length ? resolvedFields.slice(0, maxVisible) diff --git a/packages/plugin-list/src/__tests__/UserFilters.restoredDerivedChips-12001.test.tsx b/packages/plugin-list/src/__tests__/UserFilters.restoredDerivedChips-12001.test.tsx new file mode 100644 index 0000000000..7136763a62 --- /dev/null +++ b/packages/plugin-list/src/__tests__/UserFilters.restoredDerivedChips-12001.test.tsx @@ -0,0 +1,232 @@ +/** + * 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#12001 — a URL-restored quick-filter selection must survive the + * object definition loading AFTER the list mounts. + * + * THE DEFECT. `userFilters: { element: 'dropdown' }` with no `fields` is the + * shorthand `ListView` fills from the object definition (its + * `resolvedUserFilters` memo). The definition is fetched after mount, so + * `DropdownFilters` first renders with NO fields. It used to read the restored + * selection (`initialSelections`, the host's `uf_*` URL params) only in its + * `useState` initializer, which ran against that empty field list, so a link + * such as `?uf_status=open` opened unfiltered while the address bar still + * carried the filter. + * + * WHY THE DEFINITION IS GATED. `getObjectSchema` waits on a promise this file + * releases by hand, and each derived case first asserts the bar is still + * empty. Without that precondition an immediately-resolving definition could + * let the chips derive before `DropdownFilters` ever rendered empty, and the + * case would be green on the defect. + * + * WHAT IS PINNED: + * - DERIVED: the chip shows the restored selection and the LAST `find` + * carries it, with the same `$filter` the declared-fields control sends. + * - CONTROL: the declared-fields list behaves as before. + * - ONCE PER FIELD: a user's clear survives the derived field list being + * rebuilt; a restored value is adopted when its field first appears, never + * again. + * - ARRIVAL USES THE MOUNT RULES: URL strings are coerced to typed option + * values, and a single-choice field keeps one value. + * - TABS (measured, not a defect): presets are never derived from the + * definition, so a restored tab already applies under the same late load. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, cleanup, act } from '@testing-library/react'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { ListView } from '../ListView'; +import { UserFilters } from '../UserFilters'; + +const objectDef = { + name: 'ticket', + label: 'Ticket', + fields: { + id: { name: 'id', type: 'text' }, + subject: { name: 'subject', type: 'text', label: 'Subject' }, + status: { + name: 'status', + type: 'select', + label: 'Status', + options: [ + { value: 'open', label: 'Open' }, + { value: 'closed', label: 'Closed' }, + ], + }, + is_active: { name: 'is_active', type: 'boolean', label: 'Active' }, + }, +}; + +const rows = [{ id: '1', subject: 'First', status: 'open', is_active: true }]; + +/** 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()) }; +} + +const BASE = { + type: 'list-view', + objectName: 'ticket', + viewType: 'grid', + columns: ['subject', 'status'], +} as const; + +const RESTORED = { status: ['open'] }; + +function mountList(userFilters: Record, selections: Record) { + const { dataSource, release } = makeGatedDataSource(); + const ui = (schema: Record) => ( + + + + ); + const schema = { ...BASE, userFilters }; + const utils = render(ui(schema)); + return { + dataSource, + release, + schema, + rerender: (next: Record) => utils.rerender(ui(next)), + }; +} + +const lastFilter = (dataSource: any) => dataSource.find.mock.calls.at(-1)?.[1]?.$filter; + +afterEach(cleanup); + +describe('a URL-restored quick filter survives a late object definition (objectui#12001)', () => { + it('CONTROL: declared fields restore the chip and the query, as before', async () => { + const { dataSource, release } = mountList( + { element: 'dropdown', fields: [{ field: 'status', type: 'select' }] }, + RESTORED, + ); + // Declared chips exist before the definition does. + expect(screen.getByTestId('filter-badge-status')).toBeTruthy(); + await release(); + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); + expect(screen.getByTestId('filter-clear-status')).toBeTruthy(); + expect(lastFilter(dataSource)).toEqual(['status', '=', 'open']); + }); + + it('DERIVED: the chip shows the restored selection and the last find carries its $filter', async () => { + const { dataSource, release } = mountList({ element: 'dropdown' }, RESTORED); + // Precondition: the bar mounted before the definition, with nothing to + // attach the restored selection to. + expect(screen.getByTestId('user-filters-empty')).toBeTruthy(); + await release(); + await waitFor(() => expect(screen.getByTestId('filter-badge-status')).toBeTruthy()); + expect(screen.getByTestId('filter-clear-status')).toBeTruthy(); + expect(screen.getByTestId('filter-badge-status').textContent).toContain('1'); + // The same `$filter` the declared control sends. + await waitFor(() => expect(lastFilter(dataSource)).toEqual(['status', '=', 'open'])); + }); + + it('ONCE PER FIELD: a user clear survives the derived field list being rebuilt', async () => { + const { dataSource, release, schema, rerender } = mountList({ element: 'dropdown' }, RESTORED); + await release(); + await waitFor(() => expect(lastFilter(dataSource)).toEqual(['status', '=', 'open'])); + + fireEvent.click(screen.getByTestId('filter-clear-status')); + await waitFor(() => expect(lastFilter(dataSource)).toBeUndefined()); + const settled = dataSource.find.mock.calls.length; + + // A new-but-equal `userFilters` object makes `ListView` derive the field + // list again: fresh field objects under the same names, while the host + // still hands down the selections it restored at mount. + rerender({ ...schema, userFilters: { element: 'dropdown' } }); + await new Promise((r) => setTimeout(r, 50)); + + expect(screen.getByTestId('filter-badge-status')).toBeTruthy(); + expect(screen.queryByTestId('filter-clear-status')).toBeNull(); + expect(dataSource.find.mock.calls.length).toBe(settled); + expect(lastFilter(dataSource)).toBeUndefined(); + }); + + it('TABS (measured, not a defect): a restored tab applies under the same late definition', async () => { + const { dataSource, release } = mountList( + { + element: 'tabs', + tabs: [ + { name: 'all', label: 'All', isDefault: true }, + { name: 'open', label: 'Open', filter: [{ field: 'status', operator: 'equals', value: 'open' }] }, + ], + }, + { _tab: ['open'] }, + ); + await release(); + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); + expect(JSON.stringify(lastFilter(dataSource))).toContain('"open"'); + }); +}); + +describe('DropdownFilters adopts a restored value when its field first appears (objectui#12001)', () => { + const mountBar = (fields: Array<{ field: string; type?: string }>, def: unknown, props: Record) => ( + + ); + + it('ARRIVAL USES THE MOUNT RULES: URL strings are coerced and single-choice keeps one value', () => { + const onFilterChange = vi.fn(); + const initialSelections = { status: ['open', 'closed'], is_active: ['true'] }; + const { rerender } = render(mountBar([], undefined, { onFilterChange, initialSelections })); + expect(onFilterChange).not.toHaveBeenCalled(); + + rerender( + mountBar( + [ + { field: 'status', type: 'select' }, + { field: 'is_active', type: 'boolean' }, + ], + objectDef, + { onFilterChange, initialSelections }, + ), + ); + + expect(onFilterChange).toHaveBeenLastCalledWith([ + ['status', 'in', ['open']], + ['is_active', 'in', [true]], + ]); + }); + + it('ONCE PER FIELD: a field present at mount is not re-adopted when the list is rebuilt', () => { + const onFilterChange = vi.fn(); + const initialSelections = { status: ['open'] }; + const fields = [{ field: 'status', type: 'select' }]; + const { rerender } = render(mountBar(fields, objectDef, { onFilterChange, initialSelections })); + fireEvent.click(screen.getByTestId('filter-clear-status')); + expect(onFilterChange).toHaveBeenLastCalledWith([]); + const settled = onFilterChange.mock.calls.length; + + rerender(mountBar([{ field: 'status', type: 'select' }], { ...objectDef }, { onFilterChange, initialSelections })); + + expect(screen.queryByTestId('filter-clear-status')).toBeNull(); + expect(onFilterChange.mock.calls.length).toBe(settled); + }); +}); From 5c7d7533e82f97706e5889b021345a57d9e3f66b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 20:33:08 +0000 Subject: [PATCH 2/2] chore(changeset): plugin-list patch for the restored derived quick-filter chips (objectui#12001) Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .changeset/12001-restored-derived-chips.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/12001-restored-derived-chips.md diff --git a/.changeset/12001-restored-derived-chips.md b/.changeset/12001-restored-derived-chips.md new file mode 100644 index 0000000000..6ab6bd1083 --- /dev/null +++ b/.changeset/12001-restored-derived-chips.md @@ -0,0 +1,13 @@ +--- +'@object-ui/plugin-list': patch +--- + +A quick-filter selection restored from the URL now applies when the list's dropdown chips come from the object definition (objectui#12001). + +`userFilters: { element: 'dropdown' }` with no `fields` fills its chips from the object definition, and that definition loads after the list mounts. The dropdown bar read the restored selection (`initialSelections`, which the console fills from `uf_*` URL params) only once, at mount, when it had no fields yet. So a shared link such as `?uf_status=open` opened the list unfiltered while the address bar still carried the filter. + +Now a field that appears after mount gets the same starting selection it would have had at mount: the author's default, then the restored value, converted to the option's value type, with a single-choice field kept to one value. The chip shows it and the query carries it. Each field gets this once, so a value the user picks or clears afterwards is not overwritten when the definition renders again. As with a restore at mount, the bar reports it through `onFilterChange` and does not call `onSelectionsChange`. + +A list with declared `fields` behaves as before. The `tabs` mode was measured and is not affected: its presets come from the view, not the definition. The deprecated `toggle` mode takes no restored selection in any list. + +No export, prop, type or language-pack key changes.