diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 1bada455d9..1fd39ad6d1 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -2427,6 +2427,11 @@ export * from './widgets/ObjectRefField'; export * from './widgets/FilterConditionField'; export * from './widgets/RecipientPickerField'; export * from './widgets/RecordPickerDialog'; +// Shared picker-column derivation (ADR-0085 highlightFields → displayFields → +// schema walk) — consumed by LookupField AND by RelatedList's Add-picker so a +// lookup picker and a related-list Add dialog of the same object agree on +// columns (#3365). +export * from './widgets/deriveLookupColumns'; export * from './widgets/FileField'; export * from './widgets/ImageField'; export { ImageCropperDialog } from './widgets/ImageCropperDialog'; diff --git a/packages/plugin-detail/src/RelatedList.tsx b/packages/plugin-detail/src/RelatedList.tsx index 5cc465b452..f41fe2b17b 100644 --- a/packages/plugin-detail/src/RelatedList.tsx +++ b/packages/plugin-detail/src/RelatedList.tsx @@ -39,7 +39,7 @@ import { } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import type { DataSource, FieldMetadata } from '@object-ui/types'; -import { getCellRenderer, resolveCellRendererType, RecordPickerDialog } from '@object-ui/fields'; +import { getCellRenderer, resolveCellRendererType, RecordPickerDialog, deriveLookupColumns } from '@object-ui/fields'; import { columnIdentity, compareSortValues, @@ -369,6 +369,30 @@ export const RelatedList: React.FC = ({ } }, [api, dataSource]); + // Add-picker target schema, fetched lazily on first open. It drives the + // picker's display column (`add.picker.labelField` → displayField), the + // auto-derived multi-column layout, and type-aware cell rendering — without + // it the dialog fell back to a single title-cased NAME column showing + // machine names even though the page metadata declared `labelField: 'label'` + // (#3365: sys_position / sys_permission_set Add pickers). + const pickerObject = add?.picker?.object; + const [pickerSchema, setPickerSchema] = React.useState(null); + React.useEffect(() => { + if (!pickerOpen || !pickerObject || pickerSchema || !dataSource?.getObjectSchema) return; + let cancelled = false; + dataSource.getObjectSchema(pickerObject).then((s: any) => { + if (!cancelled) setPickerSchema(s); + }).catch((err: unknown) => { + console.warn(`[RelatedList] Failed to fetch schema for ${pickerObject}:`, err); + }); + return () => { cancelled = true; }; + }, [pickerOpen, pickerObject, pickerSchema, dataSource]); + const pickerDisplayField = add?.picker?.labelField || 'name'; + const pickerColumns = React.useMemo(() => { + const derived = deriveLookupColumns(pickerSchema, { displayField: pickerDisplayField }); + return derived.length > 0 ? derived : undefined; + }, [pickerSchema, pickerDisplayField]); + React.useEffect(() => { // Stale-response guard: page flips re-run this effect while an earlier // window may still be in flight — a slow page-2 response must not @@ -1267,6 +1291,10 @@ export const RelatedList: React.FC = ({ dataSource={dataSource as any} objectName={add.picker.object} title={add.label || t('detail.add', { defaultValue: 'Add' })} + displayField={pickerDisplayField} + columns={pickerColumns} + cellRenderer={getCellRenderer} + fieldsMeta={pickerSchema?.fields} onSelect={() => {}} onSelectRecords={(records: any[]) => { void handleAddRecords(records); }} /> diff --git a/packages/plugin-detail/src/__tests__/RelatedList.addPickerLabelField.test.tsx b/packages/plugin-detail/src/__tests__/RelatedList.addPickerLabelField.test.tsx new file mode 100644 index 0000000000..bb7d4b8183 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/RelatedList.addPickerLabelField.test.tsx @@ -0,0 +1,128 @@ +/** + * 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#3365 — the related-list Add picker must honour + * `add.picker.labelField`. + * + * Pre-fix the dialog received only `objectName`, so it fell back to its + * `displayField = 'name'` default with a single title-cased NAME column: + * assigning a position / granting a permission set showed machine names + * (`admin_full_access`) even though the platform page metadata declared + * `picker: { object: 'sys_permission_set', labelField: 'label' }`. + * + * Now the picker leads with `labelField`, derives its columns (and their + * headers) from the target object's schema via the shared + * `deriveLookupColumns`, and — when the schema cannot be fetched — still + * displays the `labelField` column instead of `name`. + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import * as React from 'react'; +import { RelatedList } from '../RelatedList'; + +// The list body itself is irrelevant here — only the Add dialog matters. +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, SchemaRenderer: () => null }; +}); + +const junctionSchema = { + name: 'sys_user_permission_set', + fields: { + permission_set_id: { + type: 'lookup', + label: 'Permission Set', + reference_to: 'sys_permission_set', + }, + }, +}; + +// The exact sys_permission_set shape: `name` is the machine/API name, the +// human-readable display name lives in `label`. +const permissionSetSchema = { + name: 'sys_permission_set', + nameField: 'label', + fields: { + name: { type: 'text', label: 'API Name' }, + label: { type: 'text', label: '权限集名称' }, + }, +}; + +const candidates = [ + { id: 'ps_1', name: 'admin_full_access', label: '管理员完全访问' }, + { id: 'ps_2', name: 'ehr_operator', label: 'EHR 操作员' }, +]; + +const makeDataSource = () => ({ + getObjectSchema: vi.fn(async (api: string) => + api === 'sys_permission_set' ? permissionSetSchema : junctionSchema, + ), + find: vi.fn(async (api: string) => + api === 'sys_permission_set' + ? { data: candidates, total: candidates.length } + : { data: [], total: 0 }, + ), +}); + +const renderList = (ds: any) => + render( + , + ); + +describe('RelatedList Add picker — add.picker.labelField (#3365)', () => { + it('shows labelField values with schema-derived column headers', async () => { + const ds = makeDataSource(); + renderList(ds); + + fireEvent.click(screen.getByRole('button', { name: /Grant permission set/ })); + + await waitFor(() => + expect(ds.getObjectSchema).toHaveBeenCalledWith('sys_permission_set'), + ); + // Candidate rows show the human-readable labelField value… + await waitFor(() => + expect(screen.getByText('管理员完全访问')).toBeInTheDocument(), + ); + expect(screen.getByText('EHR 操作员')).toBeInTheDocument(); + // …and the leading column header is the schema field's label, not + // titleCase('name'). + expect(screen.getByText('权限集名称')).toBeInTheDocument(); + }); + + it('still leads with labelField when the picker schema is unavailable', async () => { + const ds = makeDataSource(); + ds.getObjectSchema.mockImplementation(async (api: string) => { + if (api === 'sys_permission_set') throw new Error('no schema'); + return junctionSchema; + }); + renderList(ds); + + fireEvent.click(screen.getByRole('button', { name: /Grant permission set/ })); + + // No schema → no derived columns, but displayField alone must already + // surface the human-readable label instead of the machine name. + await waitFor(() => + expect(screen.getByText('管理员完全访问')).toBeInTheDocument(), + ); + }); +});