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
5 changes: 5 additions & 0 deletions packages/fields/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
30 changes: 29 additions & 1 deletion packages/plugin-detail/src/RelatedList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -369,6 +369,30 @@ export const RelatedList: React.FC<RelatedListProps> = ({
}
}, [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<any>(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
Expand Down Expand Up @@ -1267,6 +1291,10 @@ export const RelatedList: React.FC<RelatedListProps> = ({
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); }}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>();
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(
<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={ds}
add={{
picker: { object: 'sys_permission_set', labelField: 'label' },
linkField: 'permission_set_id',
label: 'Grant permission set',
}}
/>,
);

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