From 640eae5c4fc0465e0f0385a4c514d03167b97c6d Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Thu, 6 Aug 2026 02:30:13 +0000 Subject: [PATCH] fix(fields): record picker filter panel offers the schema's select options (#3336) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lookup "Browse all records" picker derives a filter column from every typed picker column, but those carried no `options` — so the filter panel's `select` input rendered `col.options?.map(...)` over `undefined` and the dropdown opened EMPTY, leaving the column unfilterable. The same column's table CELLS had resolved their option labels since #3333. RecordPickerDialog now fills a `select` filter column's missing `options` from `fieldsMeta` (the referenced object's schema `fields` map) through `resolveSchemaOptions` — the same resolver, and the same i18n option translation, the cell descriptors use — so the filter dropdown and the cells cannot drift apart. Explicitly authored filter options still win (including the ones auto-derived from an `in`/`notIn` `lookup_filters` entry), and a schema field that declares no options keeps an empty dropdown: nothing is synthesised from the loaded page's raw stored values. LookupField is unchanged: it already passes `fieldsMeta`, and resolving the options in the dialog keeps ONE source for both faces of the widget instead of a second, filter-only derivation. Fixes objectstack-ai/objectui#3336 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --- .../record-picker-filter-select-options.md | 19 + .../RecordPickerDialog.filterOptions.test.tsx | 366 ++++++++++++++++++ .../fields/src/widgets/RecordPickerDialog.tsx | 69 +++- 3 files changed, 450 insertions(+), 4 deletions(-) create mode 100644 .changeset/record-picker-filter-select-options.md create mode 100644 packages/fields/src/widgets/RecordPickerDialog.filterOptions.test.tsx diff --git a/.changeset/record-picker-filter-select-options.md b/.changeset/record-picker-filter-select-options.md new file mode 100644 index 0000000000..77246b1d42 --- /dev/null +++ b/.changeset/record-picker-filter-select-options.md @@ -0,0 +1,19 @@ +--- +"@object-ui/fields": patch +--- + +The lookup "Browse all records" Record Picker's filter panel now offers the +options a `select` field declares in its schema (objectui#3336). `LookupField` +turns each typed picker column into a filter column, and those carried no +`options` — so the filter panel's dropdown opened EMPTY and the column could +not be filtered at all, even though the same column's table cells had rendered +the authored option labels since objectui#3333. + +`RecordPickerDialog` now fills a `select` filter column's missing `options` +from `fieldsMeta` (the referenced object's schema `fields` map) through the +same resolver — and the same i18n option translation — the table cells use, so +the filter dropdown and the cells can never disagree about what an option is +called. Explicitly authored filter `options` still win (including the ones +auto-derived from an `in`/`notIn` `lookup_filters` entry), and a select field +whose schema declares no options keeps an empty dropdown: no options are +synthesised from the loaded page's raw stored values. diff --git a/packages/fields/src/widgets/RecordPickerDialog.filterOptions.test.tsx b/packages/fields/src/widgets/RecordPickerDialog.filterOptions.test.tsx new file mode 100644 index 0000000000..2d8ad3b5db --- /dev/null +++ b/packages/fields/src/widgets/RecordPickerDialog.filterOptions.test.tsx @@ -0,0 +1,366 @@ +/** + * 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. + */ + +/** + * Record Picker FILTER PANEL — select filter inputs carry the schema's options + * (#3336). + * + * #3333 fixed the picker's table CELLS: they now format through the referenced + * object's schema `fields` map (`fieldsMeta`) plus the shared i18n option + * translation, so a `select` column renders the authored option label instead + * of title-casing the raw stored value. + * + * The filter panel is the other face of the same widget and was still broken. + * `LookupField` turns each typed picker column into a `RecordPickerFilterColumn` + * (`{ field, label, type: 'select' }`) with no `options`, and the panel's select + * input renders `col.options?.map(...)` — so the dropdown opened EMPTY and the + * column could not be filtered at all. + * + * ## What these cases assert, and in which direction + * + * - **The two reproducers (`renderFilterBar` slot + built-in panel DOM) were + * RED before the fix**: `options` was `undefined` and the dropdown rendered + * zero `option` roles. + * - **The parity case is the load-bearing one.** It asserts the filter option + * label and the table cell for the SAME field are the same translated string, + * under an `I18nProvider` that translates `fieldOptions...`. + * That is what pins "one source" rather than "two derivations that happen to + * agree today" — a second, filter-only derivation reading `meta.options` + * directly would satisfy the label assertions but not this one. + * - **Two cases are deliberate NO-OP pins, green before and after** (called out + * individually below): a schema field with no `options` still yields an empty + * dropdown (the fix adds no consumer-side leniency — no options synthesised + * from the loaded page's raw values), and explicitly authored filter options + * keep winning over the schema (including the `in`-operator options + * auto-derived from `lookupFilters`). They guard the precedence and the + * non-leniency the fix chose, not a defect it repaired. + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { I18nProvider } from '@object-ui/i18n'; +import { RecordPickerDialog } from './RecordPickerDialog'; +import type { RecordPickerFilterBarProps } from './RecordPickerDialog'; +import { LookupField } from './LookupField'; +import { getCellRenderer } from '../index'; + +// Radix Select opens on pointer events happy-dom/jsdom do not implement. +beforeAll(() => { + class MockPointerEvent extends Event { + button: number; + ctrlKey: boolean; + pointerType: string; + constructor(type: string, props: any = {}) { + super(type, props); + this.button = props.button ?? 0; + this.ctrlKey = props.ctrlKey ?? false; + this.pointerType = props.pointerType ?? 'mouse'; + } + } + (window as any).PointerEvent = MockPointerEvent; + (HTMLElement.prototype as any).hasPointerCapture = vi.fn(); + (HTMLElement.prototype as any).releasePointerCapture = vi.fn(); + (HTMLElement.prototype as any).scrollIntoView = vi.fn(); +}); + +const projects = [ + { id: 'p1', name: 'Line A retooling', project_phase: 'manufacturing' }, +]; + +/** The referenced object's schema `fields` map, as `getObjectSchema` returns it. */ +const projectFields = { + name: { type: 'text', label: 'Name' }, + project_phase: { + type: 'select', + label: 'Project Phase', + options: [ + { label: '01 Initiation', value: 'initiation' }, + { label: '03 Manufacturing', value: 'manufacturing' }, + ], + }, + // A select field the metadata author never gave options to. + risk_level: { type: 'select', label: 'Risk Level' }, +}; + +function makeDataSource() { + const find = vi.fn(async () => ({ data: projects, total: projects.length })); + const getObjectSchema = vi.fn(async () => ({ + name: 'projects', + fields: projectFields, + highlightFields: ['name', 'project_phase'], + })); + return { find, getObjectSchema } as any; +} + +const phaseFilterColumn = { field: 'project_phase', label: 'Project Phase', type: 'select' as const }; + +/** + * Open the built-in filter panel and return the first select trigger in it. + * + * The toggle is reached structurally (first button in the filter bar) rather + * than by its accessible name: one case below mounts a `zh` I18nProvider, and + * `initReactI18next` makes that instance the default for provider-less renders + * afterwards — so a `/filters/i` name match would pass or fail depending on + * test order, not on the behaviour under test. + * + * `findBy…`: the filter bar only exists once the filter columns are resolved, + * which in the LookupField path waits on the referenced object's schema fetch. + */ +async function openFilterSelect(): Promise { + const bar = await screen.findByTestId('record-picker-filter-bar'); + fireEvent.click(bar.querySelector('button')!); + const panel = await screen.findByTestId('record-picker-filter-panel'); + const trigger = panel.querySelector('[role="combobox"]'); + expect(trigger).toBeTruthy(); + await act(async () => { + fireEvent.pointerDown(trigger!, { button: 0 }); + }); + return trigger!; +} + +describe('RecordPickerDialog filter panel — select options come from the schema (#3336)', () => { + it('hands the filter bar a select column carrying the schema field options', async () => { + let captured: RecordPickerFilterBarProps | null = null; + render( + {}} + dataSource={makeDataSource()} + objectName="projects" + columns={[{ field: 'project_phase', label: 'Project Phase', type: 'select' }]} + onSelect={() => {}} + cellRenderer={getCellRenderer} + fieldsMeta={projectFields} + filterColumns={[phaseFilterColumn]} + renderFilterBar={(p) => { + captured = p; + return
; + }} + />, + ); + + await waitFor(() => expect(captured).not.toBeNull()); + // Pre-fix this was `undefined` — the panel had nothing to render. + expect(captured!.filterColumns[0].options).toEqual([ + { label: '01 Initiation', value: 'initiation' }, + { label: '03 Manufacturing', value: 'manufacturing' }, + ]); + }); + + it('renders the schema options in the built-in filter panel dropdown', async () => { + render( + {}} + dataSource={makeDataSource()} + objectName="projects" + columns={[ + { field: 'name', label: 'Name', type: 'text' }, + { field: 'project_phase', label: 'Project Phase', type: 'select' }, + ]} + onSelect={() => {}} + cellRenderer={getCellRenderer} + fieldsMeta={projectFields} + filterColumns={[phaseFilterColumn]} + />, + ); + + await waitFor(() => expect(screen.getByText('Line A retooling')).toBeInTheDocument()); + await openFilterSelect(); + + // Pre-fix: SelectContent was empty, so neither option role existed. + expect(await screen.findByRole('option', { name: '03 Manufacturing' })).toBeTruthy(); + expect(screen.getByRole('option', { name: '01 Initiation' })).toBeTruthy(); + }); + + it('filters on the picked option value', async () => { + const ds = makeDataSource(); + render( + {}} + dataSource={ds} + objectName="projects" + columns={[{ field: 'project_phase', label: 'Project Phase', type: 'select' }]} + onSelect={() => {}} + cellRenderer={getCellRenderer} + fieldsMeta={projectFields} + filterColumns={[phaseFilterColumn]} + />, + ); + + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + await openFilterSelect(); + + const option = await screen.findByRole('option', { name: '03 Manufacturing' }); + await act(async () => { + fireEvent.click(option); + }); + + await waitFor(() => { + const lastParams = ds.find.mock.calls[ds.find.mock.calls.length - 1][1]; + expect(lastParams.$filter).toMatchObject({ project_phase: 'manufacturing' }); + }); + }); + + /** + * The load-bearing case: ONE source for both faces of the widget. The filter + * option label and the table cell for the same field must be the same + * translated string — a filter-only re-derivation that skipped the shared + * i18n option path would show the authored English here. + */ + it('translates filter option labels through the same i18n path as the cells', async () => { + let captured: RecordPickerFilterBarProps | null = null; + render( + + {/* A dedicated object name: the bundle above stays registered on the + default i18next instance for later renders, and keying it to + `projects_zh` keeps it from translating the other cases' options. */} + {}} + dataSource={makeDataSource()} + objectName="projects_zh" + columns={[{ field: 'project_phase', label: 'Project Phase', type: 'select' }]} + onSelect={() => {}} + cellRenderer={getCellRenderer} + fieldsMeta={projectFields} + filterColumns={[phaseFilterColumn]} + renderFilterBar={(p) => { + captured = p; + return
; + }} + /> + , + ); + + // The cell already resolved the translated label after #3333 … + await waitFor(() => expect(screen.getByText('03 制造')).toBeInTheDocument()); + // … and the filter option for the same field now says exactly the same thing. + await waitFor(() => expect(captured).not.toBeNull()); + const labels = captured!.filterColumns[0].options?.map(o => o.label); + expect(labels).toEqual(['01 立项', '03 制造']); + expect(labels).not.toContain('03 Manufacturing'); + }); + + /** + * NO-OP PIN (green before and after). A select field the author gave no + * options keeps an empty dropdown: the fix derives options from the schema + * and adds no consumer-side leniency — it never invents entries from the + * loaded page's raw stored values, which would hide the metadata gap behind + * a list that changes per page. + */ + it('leaves a select filter optionless when the schema field declares none', async () => { + let captured: RecordPickerFilterBarProps | null = null; + render( + {}} + dataSource={makeDataSource()} + objectName="projects" + columns={[{ field: 'name', label: 'Name', type: 'text' }]} + onSelect={() => {}} + cellRenderer={getCellRenderer} + fieldsMeta={projectFields} + filterColumns={[{ field: 'risk_level', label: 'Risk Level', type: 'select' }]} + renderFilterBar={(p) => { + captured = p; + return
; + }} + />, + ); + + await waitFor(() => expect(captured).not.toBeNull()); + expect(captured!.filterColumns[0].options).toBeUndefined(); + }); + + /** + * NO-OP PIN (green before and after). Authored filter options are the more + * specific statement and keep winning — including the `in`-operator options + * `lookupFilters` auto-derivation builds, which must not be replaced by the + * schema's full option set (that would widen a deliberately narrowed list). + */ + it('keeps explicitly authored filter options over the schema ones', async () => { + let captured: RecordPickerFilterBarProps | null = null; + render( + {}} + dataSource={makeDataSource()} + objectName="projects" + onSelect={() => {}} + cellRenderer={getCellRenderer} + fieldsMeta={projectFields} + lookupFilters={[{ field: 'project_phase', operator: 'in', value: ['manufacturing'] }]} + renderFilterBar={(p) => { + captured = p; + return
; + }} + />, + ); + + await waitFor(() => expect(captured).not.toBeNull()); + expect(captured!.filterColumns[0].options).toEqual([ + { label: 'manufacturing', value: 'manufacturing' }, + ]); + }); +}); + +describe('LookupField → picker filter panel wiring (#3336)', () => { + const lookup = { + name: 'project', + label: 'Project', + type: 'lookup', + reference_to: 'projects', + reference_field: 'name', + } as any; + + it('offers the referenced object schema options in the derived select filter', async () => { + render( + , + ); + + // Open the "Browse all records" picker. + await act(async () => { + fireEvent.click(await screen.findByTestId('browse-all-records')); + }); + await waitFor(() => expect(screen.getByTestId('record-picker-dialog')).toBeInTheDocument()); + + // `project_phase` reaches the filter bar because it is a typed picker column + // derived from `highlightFields`; its options come from the same schema. + await openFilterSelect(); + expect(await screen.findByRole('option', { name: '03 Manufacturing' })).toBeTruthy(); + }); +}); diff --git a/packages/fields/src/widgets/RecordPickerDialog.tsx b/packages/fields/src/widgets/RecordPickerDialog.tsx index 99acea0890..708d774004 100644 --- a/packages/fields/src/widgets/RecordPickerDialog.tsx +++ b/packages/fields/src/widgets/RecordPickerDialog.tsx @@ -185,6 +185,41 @@ export function lookupFiltersToRecord( return result; } +/** + * A select option as the object schema declares it — `{ value, label }` plus + * whatever the renderer also reads (`color`, …), which the i18n translation + * carries through untouched. + */ +type SchemaOption = { value: any; label: string; [key: string]: any }; + +/** + * Resolve a field's select options from the referenced object's schema field + * definition (`fieldsMeta[field]`), translated through the shared i18n option + * path. + * + * This is the SINGLE source for both surfaces that need option labels: the + * table cells (#3333) and the filter panel's select inputs (#3336) — so a + * picker column and the filter input for that same column can never disagree + * about what an option is called. + * + * Returns `undefined` when the schema field declares no options: a select + * field with no authored options genuinely has nothing to offer, and + * synthesising entries from the loaded page's raw stored values would paper + * over the metadata gap with a list that changes per page. + */ +function resolveSchemaOptions( + meta: { options?: unknown } | undefined, + objectName: string, + fieldName: string, + translateOptions: (o: string, f: string, opts: SchemaOption[]) => SchemaOption[], +): SchemaOption[] | undefined { + const raw = meta?.options; + if (!Array.isArray(raw) || raw.length === 0) return undefined; + const options = raw as SchemaOption[]; + if (!objectName) return options; + return translateOptions(objectName, fieldName, options); +} + /** * Convert user-entered filter bar values into a $filter Record. * Each key is a field name, each value the user-entered value. @@ -288,6 +323,11 @@ export interface RecordPickerDialogProps { * title-casing the raw stored value instead of resolving the option label * (#3333: `manufacturing` rendered as "Manufacturing" instead of the * authored option label). + * + * The filter bar reads the same map: a `select` filter column with no + * authored `options` takes them from the schema field here, so the filter + * panel's dropdown offers exactly the options the table cells render + * (#3336 — it used to open empty, leaving the field unfilterable). */ fieldsMeta?: Record; @@ -415,9 +455,8 @@ export function RecordPickerDialog({ const descriptor: any = meta ? { ...meta, name: col.field, type } : { name: col.field, type }; - if (Array.isArray(descriptor.options) && objectName) { - descriptor.options = translateOptions(objectName, col.field, descriptor.options); - } + const options = resolveSchemaOptions(meta, objectName, col.field, translateOptions); + if (options) descriptor.options = options; map[col.field] = descriptor; } return map; @@ -425,7 +464,7 @@ export function RecordPickerDialog({ // Auto-generate filter columns from lookupFilters when no explicit filterColumns given. // Each LookupFilterDef becomes a filterable field with inferred type. - const effectiveFilterColumns = useMemo(() => { + const baseFilterColumns = useMemo(() => { if (filterColumns && filterColumns.length > 0) return filterColumns; // Auto-derive from lookupFilters: each filter entry becomes a filterable field if (lookupFilters && lookupFilters.length > 0) { @@ -463,6 +502,28 @@ export function RecordPickerDialog({ return undefined; }, [filterColumns, lookupFilters]); + // Filter columns as the filter bar consumes them: every `select` filter + // carries the options its schema field declares. The filter panel's Select reads + // `col.options` — a derived select column (LookupField turns each typed picker + // column into a filter column) carries none, so the dropdown opened empty and + // the field could not be filtered at all (#3336). + // + // The options come from `fieldsMeta` through `resolveSchemaOptions`, i.e. the + // SAME schema source + i18n translation the table cells use (#3333) — not a + // second derivation that could drift from the cells. An explicitly authored + // `options` list on the filter column always wins (it is the more specific + // statement), and a schema field with no options stays optionless: an empty + // dropdown for THAT field is the honest rendering of missing metadata. + const effectiveFilterColumns = useMemo(() => { + if (!baseFilterColumns) return undefined; + return baseFilterColumns.map(col => { + if (col.type !== 'select') return col; + if (col.options && col.options.length > 0) return col; + const options = resolveSchemaOptions(fieldsMeta?.[col.field], objectName, col.field, translateOptions); + return options ? { ...col, options } : col; + }); + }, [baseFilterColumns, fieldsMeta, objectName, translateOptions]); + // Merge base lookup_filters with user filter bar values. The hard // `baseFilter` constraint (dependent-lookup chain, #2215) is spread LAST so // user filter-bar input can never widen it back out.