From 58ac528d1e7a64397bf668ce4a19a924358a8c15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Tue, 4 Aug 2026 00:09:43 -0700 Subject: [PATCH] fix(fields): record picker select columns resolve option labels via schema fieldsMeta (#3333) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lookup "Browse all records" Record Picker handed cell renderers a bare { name, type } descriptor, so a select column had no options and fell back to title-casing the raw stored value (manufacturing -> "Manufacturing") while the list view and detail page showed the authored label. RecordPickerDialog now accepts fieldsMeta (the referenced object's schema fields map) and enriches each column's field descriptor from it — options (through the shared i18n option translation), currency, scale, precision, format, reference_to — mirroring the list view's column enrichment. String lookup_columns without a type inherit the schema field's type. LookupField passes the referenced object schema it already fetches for titleFormat. Fixes objectstack-ai/objectui#3333 Co-Authored-By: Claude Fable 5 --- .changeset/record-picker-select-label.md | 20 ++++ packages/fields/src/widgets/LookupField.tsx | 1 + .../RecordPickerDialog.selectLabel.test.tsx | 112 ++++++++++++++++++ .../fields/src/widgets/RecordPickerDialog.tsx | 50 +++++++- 4 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 .changeset/record-picker-select-label.md create mode 100644 packages/fields/src/widgets/RecordPickerDialog.selectLabel.test.tsx diff --git a/.changeset/record-picker-select-label.md b/.changeset/record-picker-select-label.md new file mode 100644 index 0000000000..50eb6904d6 --- /dev/null +++ b/.changeset/record-picker-select-label.md @@ -0,0 +1,20 @@ +--- +"@object-ui/fields": patch +--- + +The lookup "Browse all records" Record Picker now formats its columns with +the same field metadata the list view uses (objectui#3333). Previously the +dialog handed cell renderers a bare `{ name, type }` descriptor, so a +`select` column had no `options` and fell back to title-casing the raw +stored value (`manufacturing` rendered as "Manufacturing" instead of the +authored option label, e.g. "03 制造") — while the same field displayed +correctly in the list view and on the record detail page. + +`RecordPickerDialog` gains an optional `fieldsMeta` prop (the referenced +object's schema `fields` map). When provided, each column's field descriptor +is enriched from the schema — `options` (run through the shared i18n option +translation), `currency`, `scale`, `precision`, `format`, `reference_to`, … — +and columns authored as plain strings in `lookup_columns` inherit the schema +field's `type`, so they format identically to typed columns. `LookupField` +passes the referenced object's schema it already fetches for `titleFormat`. +Callers that don't pass `fieldsMeta` keep the previous behavior. diff --git a/packages/fields/src/widgets/LookupField.tsx b/packages/fields/src/widgets/LookupField.tsx index 5c46b2a103..bdd299db6e 100644 --- a/packages/fields/src/widgets/LookupField.tsx +++ b/packages/fields/src/widgets/LookupField.tsx @@ -1248,6 +1248,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel lookupFilters={lookupFilters} baseFilter={dependentFilter} cellRenderer={getCellRendererResolver()} + fieldsMeta={refObjectSchema?.fields} filterColumns={filterColumns} /> )} diff --git a/packages/fields/src/widgets/RecordPickerDialog.selectLabel.test.tsx b/packages/fields/src/widgets/RecordPickerDialog.selectLabel.test.tsx new file mode 100644 index 0000000000..0eee90632b --- /dev/null +++ b/packages/fields/src/widgets/RecordPickerDialog.selectLabel.test.tsx @@ -0,0 +1,112 @@ +/** + * 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 select-column label resolution — #3333. + * + * The "Browse all records" picker table must format cells with the SAME + * field metadata the list view uses. Pre-fix the dialog handed the cell + * renderer a bare `{ name, type }` descriptor, so a `select` column had no + * `options` and fell back to title-casing the raw stored value + * (`manufacturing` → "Manufacturing" instead of the authored label). + * + * With `fieldsMeta` (the referenced object's schema `fields` map) the dialog + * enriches each column's field descriptor — options, currency, scale, … — + * so select columns resolve their option labels, and string-authored + * `lookup_columns` (no `type` on the column def) inherit the schema field's + * type and format identically. + */ + +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { RecordPickerDialog } from './RecordPickerDialog'; +import { getCellRenderer } from '../index'; + +const projects = [ + { id: 'p1', name: 'Line A retooling', project_phase: 'manufacturing' }, +]; + +const projectFields = { + name: { type: 'text', label: 'Name' }, + project_phase: { + type: 'select', + label: 'Project Phase', + options: [ + { label: '01 立项', value: 'initiation' }, + { label: '03 制造', value: 'manufacturing' }, + ], + }, +}; + +function makeDataSource() { + const find = vi.fn(async () => ({ data: projects, total: projects.length })); + return { find } as any; +} + +describe('RecordPickerDialog — select columns resolve option labels (#3333)', () => { + it('renders the authored option label when fieldsMeta provides options', 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} + />, + ); + + await waitFor(() => { + expect(screen.getByText('03 制造')).toBeInTheDocument(); + }); + expect(screen.queryByText('Manufacturing')).not.toBeInTheDocument(); + }); + + it('string-authored columns without a type inherit the schema field type', async () => { + render( + {}} + dataSource={makeDataSource()} + objectName="projects" + columns={['name', 'project_phase']} + onSelect={() => {}} + cellRenderer={getCellRenderer} + fieldsMeta={projectFields} + />, + ); + + await waitFor(() => { + expect(screen.getByText('03 制造')).toBeInTheDocument(); + }); + expect(screen.queryByText('Manufacturing')).not.toBeInTheDocument(); + }); + + it('falls back to the humanized raw value without fieldsMeta (legacy behavior)', async () => { + render( + {}} + dataSource={makeDataSource()} + objectName="projects" + columns={[{ field: 'project_phase', label: 'Project Phase', type: 'select' }]} + onSelect={() => {}} + cellRenderer={getCellRenderer} + />, + ); + + await waitFor(() => { + expect(screen.getByText('Manufacturing')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/fields/src/widgets/RecordPickerDialog.tsx b/packages/fields/src/widgets/RecordPickerDialog.tsx index 17dbfff849..99acea0890 100644 --- a/packages/fields/src/widgets/RecordPickerDialog.tsx +++ b/packages/fields/src/widgets/RecordPickerDialog.tsx @@ -38,6 +38,7 @@ import { X, } from 'lucide-react'; import type { DataSource, LookupColumnDef, LookupFilterDef } from '@object-ui/types'; +import { useSafeFieldLabel } from '@object-ui/i18n'; import { useFieldTranslation } from './useFieldTranslation'; import { useRecordQuery } from './useRecordQuery'; @@ -278,6 +279,18 @@ export interface RecordPickerDialogProps { */ cellRenderer?: CellRendererResolver; + /** + * The referenced object's schema `fields` map (field name → field + * definition). When provided, cell renderers receive the FULL field + * metadata — `options`, `currency`, `scale`, `precision`, `format`, + * `reference_to`, … — exactly like the list view enriches its columns from + * the object schema. Without it a `select` column falls back to + * title-casing the raw stored value instead of resolving the option label + * (#3333: `manufacturing` rendered as "Manufacturing" instead of the + * authored option label). + */ + fieldsMeta?: Record; + /** * Filter bar column definitions. * When provided, shows an inline filter bar below the search input. @@ -344,11 +357,13 @@ export function RecordPickerDialog({ lookupFilters, baseFilter, cellRenderer, + fieldsMeta, filterColumns, renderFilterBar, renderGrid, }: RecordPickerDialogProps) { const { t } = useFieldTranslation(); + const { translateOptions } = useSafeFieldLabel(); // Query state (records/loading/error/total + page/search/sort) lives in the // shared useRecordQuery kernel — instantiated after mergedFilter below. @@ -385,6 +400,29 @@ export function RecordPickerDialog({ return [{ field: displayField, label: fieldToLabel(displayField) }]; }, [columnsProp, displayField]); + // Field descriptors handed to the type-aware cell renderers, enriched from + // the referenced object's schema (`fieldsMeta`) the same way the list view + // enriches its columns. This is what lets a `select` column resolve its + // option label (options + i18n) instead of title-casing the raw value + // (#3333). Columns whose def carries no `type` inherit the schema field's + // type so authored string `lookup_columns` format identically. + const columnFieldDescriptors = useMemo>(() => { + const map: Record = {}; + for (const col of resolvedColumns) { + const meta = fieldsMeta?.[col.field]; + const type = col.type ?? meta?.type; + if (!type) continue; + 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); + } + map[col.field] = descriptor; + } + return map; + }, [resolvedColumns, fieldsMeta, objectName, translateOptions]); + // Auto-generate filter columns from lookupFilters when no explicit filterColumns given. // Each LookupFilterDef becomes a filterable field with inferred type. const effectiveFilterColumns = useMemo(() => { @@ -637,11 +675,13 @@ export function RecordPickerDialog({ const val = record[col.field]; - // Use type-aware renderer when column type and resolver are available - if (col.type && cellRenderer) { - const Renderer = cellRenderer(col.type); + // Use type-aware renderer when a field descriptor (column `type`, or the + // schema field's type via `fieldsMeta`) and a resolver are available. + const descriptor = columnFieldDescriptors[col.field]; + if (descriptor && cellRenderer) { + const Renderer = cellRenderer(descriptor.type); if (Renderer) { - return ; + return ; } } @@ -657,7 +697,7 @@ export function RecordPickerDialog({ } if (typeof val === 'boolean') return val ? 'Yes' : 'No'; return String(val); - }, [cellRenderer, titleFormat, displayField]); + }, [cellRenderer, titleFormat, displayField, columnFieldDescriptors]); // Render sort indicator for a column const renderSortIcon = useCallback((field: string) => {