Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 46 additions & 14 deletions packages/plugin-detail/src/RelatedList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { getCellRenderer, resolveCellRendererType, RecordPickerDialog } from '@o
import {
columnIdentity,
compareSortValues,
getRecordDisplayName,
getSortValue,
isExpandableFieldType,
userActionPredicates,
Expand Down Expand Up @@ -163,6 +164,40 @@ function resolveIconComponent(name: string | undefined): LucideIcon {
return ((lucideIcons as Record<string, LucideIcon>)[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 #<id>` / `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`.
Expand Down Expand Up @@ -491,25 +526,22 @@ export const RelatedList: React.FC<RelatedListProps> = ({
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<string, string> = {};
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 };
})
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>();
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(
<RelatedList
title="Permission Sets"
type="table"
api="sys_user_permission_set"
objectName="sys_user_permission_set"
referenceField="user_id"
parentId="u_1"
columns={[{ accessorKey: 'permission_set_id', header: 'Permission Set' }]}
dataSource={dataSource as any}
/>,
);

// 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(
<RelatedList
title="Permission Sets"
type="table"
api="sys_user_permission_set"
objectName="sys_user_permission_set"
referenceField="user_id"
parentId="u_1"
columns={[{ accessorKey: 'permission_set_id', header: 'Permission Set' }]}
dataSource={dataSource as any}
/>,
);

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'));
});
});
Loading