diff --git a/packages/plugin-detail/src/RelatedList.tsx b/packages/plugin-detail/src/RelatedList.tsx index a8951bd95..5cc465b45 100644 --- a/packages/plugin-detail/src/RelatedList.tsx +++ b/packages/plugin-detail/src/RelatedList.tsx @@ -43,6 +43,7 @@ import { getCellRenderer, resolveCellRendererType, RecordPickerDialog } from '@o import { columnIdentity, compareSortValues, + getRecordDisplayName, getSortValue, isExpandableFieldType, userActionPredicates, @@ -163,6 +164,40 @@ function resolveIconComponent(name: string | undefined): LucideIcon { return ((lucideIcons as Record)[pascal]) || Inbox; } +/** + * Resolve one referenced record to the label its cell should show. + * + * Goes through the unified ADR-0079 resolver when the target object's schema + * is available, so an object that declares its display name (e.g. + * `sys_permission_set` with `nameField: 'label'`, whose `name` is the API + * name) resolves to the same string the cell's own `useLookupName` fetch + * shows — the two used to disagree, making the column flash from the display + * name to the API name once this batch map landed (objectui#3330). Falls back + * to the legacy hard-coded chain when no schema reached us or the resolver + * bottoms out at its `Record #` / `Untitled` floor. + */ +function resolveRelatedLookupLabel(record: any, refSchema: any): string | undefined { + const id = record?.id ?? record?._id; + if (refSchema) { + const resolved = getRecordDisplayName(refSchema, record); + const isFloor = + resolved === 'Untitled' || (id != null && resolved === `Record #${id}`); + if (resolved && !isFloor) return resolved; + } + return ( + record?.full_name || + record?.fullname || + record?.display_name || + record?.name || + record?.subject || + record?.title || + record?.label || + record?.code || + record?.email || + (id != null ? String(id) : undefined) + ); +} + /** * Normalize the spec `sort` union (`'field'` / `'-field'` string or * `[{field, order}]`) into the object-array form fed to `$orderby`. @@ -491,25 +526,22 @@ export const RelatedList: React.FC = ({ let cancelled = false; Promise.all( tasks.map(({ fieldName, target, ids }) => - dataSource - .find(target, { $filter: { id: { $in: ids } }, $top: ids.length }) - .then((res: any) => { + Promise.all([ + dataSource.find(target, { $filter: { id: { $in: ids } }, $top: ids.length }), + // Target object's schema (nameField / titleFormat) so the batch map + // resolves display names through the same ADR-0079 resolver as the + // cell's own fetch — see resolveRelatedLookupLabel (objectui#3330). + typeof dataSource.getObjectSchema === 'function' + ? dataSource.getObjectSchema(target).catch(() => undefined) + : Promise.resolve(undefined), + ]) + .then(([res, refSchema]: [any, any]) => { const records: any[] = Array.isArray(res) ? res : res?.data || []; const map: Record = {}; for (const r of records) { const id = r?.id || r?._id; if (!id) continue; - map[String(id)] = - r?.full_name || - r?.fullname || - r?.display_name || - r?.name || - r?.subject || - r?.title || - r?.label || - r?.code || - r?.email || - String(id); + map[String(id)] = resolveRelatedLookupLabel(r, refSchema) ?? String(id); } return { fieldName, map }; }) diff --git a/packages/plugin-detail/src/__tests__/RelatedList.lookupLabelResolution.test.tsx b/packages/plugin-detail/src/__tests__/RelatedList.lookupLabelResolution.test.tsx new file mode 100644 index 000000000..1674fdce9 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/RelatedList.lookupLabelResolution.test.tsx @@ -0,0 +1,145 @@ +/** + * 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#3330 — the batch id → label map must resolve through the target + * object's declared display name, not a hard-coded key chain. + * + * `sys_permission_set` declares `nameField: 'label'` while its `name` field + * holds the API name (`ehr_production_planner`). The cell's own on-demand + * fetch (`useLookupName`) already resolves through ADR-0079 and rendered the + * display name — then this list's batch map landed with `name` and, injected + * as `options` (which outrank the resolved name inside LookupCellRenderer), + * flipped the whole column to API names. One resolver now serves both paths. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import * as React from 'react'; +import { RelatedList } from '../RelatedList'; + +// Capture the schema RelatedList hands to SchemaRenderer (the data-table), so +// we can render a column's cell directly and read the label it shows. +const h = vi.hoisted(() => ({ schema: null as any })); +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + SchemaRenderer: (props: any) => { + h.schema = props.schema; + return null; + }, + }; +}); + +const junctionSchema = { + name: 'sys_user_permission_set', + fields: { + permission_set_id: { + type: 'lookup', + label: 'Permission Set', + reference_to: 'sys_permission_set', + }, + }, +}; + +// The target object declares its display name lives in `label` — `name` is +// the machine/API name, exactly the sys_permission_set shape. +const permissionSetSchema = { + name: 'sys_permission_set', + nameField: 'label', + fields: { + label: { type: 'text', label: 'Display Name' }, + name: { type: 'text', label: 'API Name' }, + }, +}; + +const links = [{ id: 'l1', user_id: 'u_1', permission_set_id: 'ps_1' }]; + +const permissionSets = [ + { id: 'ps_1', name: 'ehr_production_planner', label: '排班员权限' }, +]; + +const makeDataSource = () => ({ + getObjectSchema: vi.fn(async (api: string) => + api === 'sys_permission_set' ? permissionSetSchema : junctionSchema, + ), + find: vi.fn(async (api: string, params: any) => { + if (api === 'sys_permission_set') { + const ids: string[] = params?.$filter?.id?.$in ?? []; + return { data: permissionSets.filter((r) => ids.includes(r.id)) }; + } + return { data: links, total: links.length }; + }), +}); + +/** Render the permission_set_id column's cell for `value`, return its text. */ +const renderCellText = (value: string): string | undefined => { + const col = h.schema?.columns?.find((c: any) => c.accessorKey === 'permission_set_id'); + if (!col?.cell) return undefined; + const { container, unmount } = render(<>{col.cell(value)}); + const text = container.textContent ?? undefined; + unmount(); + return text; +}; + +beforeEach(() => { + h.schema = null; +}); + +describe('RelatedList batch lookup-label resolution (objectui#3330)', () => { + it('resolves through the target object nameField, not the hard-coded chain', async () => { + const dataSource = makeDataSource(); + render( + , + ); + + // Wait for the batch id → label fetch against the target object. + await waitFor(() => + expect(dataSource.find).toHaveBeenCalledWith('sys_permission_set', expect.anything()), + ); + + // Once the map lands, the cell must show the declared display name — + // never flip to the API name the legacy chain would have picked. + await waitFor(() => expect(renderCellText('ps_1')).toBe('排班员权限')); + }); + + it('falls back to the legacy chain when the target schema is unavailable', async () => { + const dataSource = makeDataSource(); + (dataSource.getObjectSchema as any).mockImplementation(async (api: string) => { + if (api === 'sys_permission_set') throw new Error('no schema'); + return junctionSchema; + }); + render( + , + ); + + await waitFor(() => + expect(dataSource.find).toHaveBeenCalledWith('sys_permission_set', expect.anything()), + ); + // Non-regressive: without a schema the old `name`-first chain still applies. + await waitFor(() => expect(renderCellText('ps_1')).toBe('ehr_production_planner')); + }); +});