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
53 changes: 53 additions & 0 deletions .changeset/7155-converge-lookup-dialect-camelcase.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
'@object-ui/types': minor
'@object-ui/fields': minor
'@object-ui/plugin-grid': minor
'@object-ui/app-shell': minor
'@object-ui/plugin-detail': minor
---

Converge the lookup/user widget metadata on the spec's camelCase — one concept, one
spelling (objectui#7155, maintainer ruling A′ of 2026-09-03, director decision batch #19).

**BREAKING, deliberately, with no deprecation window.**

Two published contracts declared OPPOSITE dialects for the same four lookup keys, and
`@object-ui/fields`' read chains served both — snake FIRST, so the dialect the object
contract *refuses* outranked the one it *declares*:

| | `@objectstack/spec` `FieldSchema` (object metadata) | `@object-ui/types` `LookupFieldMetadata` (widget metadata) |
|---|---|---|
| camelCase | **declared** | compile error (`TS2561`) |
| snake_case | refused (`unrecognized_keys`) | **declared** |

`LookupFieldMetadata` and `UserFieldMetadata` now declare the spec spellings, and the
snake members are **removed**:

| before (removed) | after |
|---|---|
| `display_field` | `displayField` |
| `description_field` | `descriptionField` |
| `lookup_filters` | `lookupFilters` |
| `id_field` | `idField` |

**Migration.** Rename those four keys wherever you author lookup or user field metadata
— `LookupFieldMetadata` / `UserFieldMetadata` objects, and any `DataSource.getObjectSchema`
that returns them. The old spellings are no longer read: a def still carrying
`display_field` falls back to the referenced record's generic name heuristic rather than
the field you named.

`idField` is kept as a **widget-contract** key. It carries objectstack#3508's machine-name
hydration — committing a record field other than the id as the lookup's stored value —
which is picker behaviour with no `FieldSchema` twin, and none owed.

**Not renamed** (outside this ruling's four keys, still snake on the widget bag):
`reference_to`, `title_format`, `lookup_columns`, `lookup_page_size`, `depends_on`,
`allow_create`, `avatar_field`. `reference_to` in particular **stays** — the adapter's
`normalizeSchemaReferenceKeys` choke point genuinely stamps it onto every def.

Also moved with the rename: `content/docs/fields/lookup.mdx` and `user.mdx` (whose
snippets CI compiles against the built `d.ts`), all seven in-repo producers, and the
inline-edit enrichment allow-list in `@object-ui/plugin-detail`. `plugin-grid`'s
`relationalMetaKeys.ts` drops the four `legacy-alias` verdicts and retires that verdict
class; its gate is restated to assert the class no longer exists rather than passing
vacuously.
12 changes: 6 additions & 6 deletions content/docs/fields/lookup.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,15 @@ const accountId: LookupFieldMetadata = {
required: true,
reference_to: 'accounts',
reference_field: 'name',
description_field: 'industry',
id_field: '_id',
descriptionField: 'industry',
idField: '_id',
multiple: false,
searchable: true,
allow_create: true,
// Record Picker dialog (Enterprise): columns accept a field name or a descriptor.
lookup_columns: ['name', { field: 'industry', label: 'Industry', width: '160px' }],
lookup_page_size: 10,
lookup_filters: [{ field: 'active', operator: 'eq', value: true }],
lookupFilters: [{ field: 'active', operator: 'eq', value: true }],
};
```

Expand Down Expand Up @@ -85,7 +85,7 @@ When a `DataSource` is available (via `SchemaRendererContext`, explicit prop, or
label: 'Customer',
reference_to: 'customers',
reference_field: 'name', // Display field (default: 'name')
description_field: 'industry', // Optional secondary field
descriptionField: 'industry', // Optional secondary field
}
```

Expand Down Expand Up @@ -118,7 +118,7 @@ The full **RecordPickerDialog** can be opened in two ways:
label: 'Order',
reference_to: 'orders',
reference_field: 'order_number',
description_field: 'customer_name',
descriptionField: 'customer_name',
lookup_columns: [
{ field: 'order_number', label: 'Order #' },
{ field: 'customer_name', label: 'Customer' },
Expand Down Expand Up @@ -179,6 +179,6 @@ import { LookupCellRenderer } from '@object-ui/fields';
- **Secondary Field Display**: Show description/subtitle per option
- **Quick-Create Entry**: Optional "Create new" button when no results
- **Configurable Columns**: `lookup_columns` for multi-column picker display
- **Base Filters**: `lookup_filters` to restrict selectable records
- **Base Filters**: `lookupFilters` to restrict selectable records
- **Pagination**: Page-by-page navigation in Record Picker dialog
- **Backward Compatible**: Falls back to static options when no DataSource
2 changes: 1 addition & 1 deletion content/docs/fields/user.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const owner: UserFieldMetadata = {
picker: 'search',
subtitle: ['primary_business_unit_id.name', 'email'],
avatar_field: 'image',
lookup_filters: [{ field: 'banned', operator: 'ne', value: true }],
lookupFilters: [{ field: 'banned', operator: 'ne', value: true }],
};
```

Expand Down
18 changes: 12 additions & 6 deletions packages/app-shell/src/utils/paramToField.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,13 @@ describe('paramToField', () => {
expect(paramToField(p({ type: 'checkbox' }))).toMatchObject({ type: 'boolean', widget: 'checkbox' });
});

it('maps the full lookup picker config to snake_case field metadata', () => {
// ⭐ objectui#7155 converged the lookup dialect: `displayField` / `idField` /
// `descriptionField` / `lookupFilters` are emitted in the SPEC spelling now.
// The remaining snake members below (`reference_to`, `title_format`,
// `lookup_columns`, `lookup_page_size`, `depends_on`) were outside that
// ruling's four keys and are unchanged — this mixed shape is deliberate, and
// asserting it keeps the two halves visibly separate.
it('maps the full lookup picker config to the field metadata the widgets read', () => {
const field = paramToField(p({
type: 'lookup',
referenceTo: 'space_users',
Expand All @@ -120,13 +126,13 @@ describe('paramToField', () => {
expect(field).toMatchObject({
type: 'lookup',
reference_to: 'space_users',
display_field: 'name',
id_field: 'id',
description_field: 'email',
displayField: 'name',
idField: 'id',
descriptionField: 'email',
multiple: true,
title_format: '{first_name} {last_name}',
lookup_columns: [{ field: 'name' }],
lookup_filters: [{ field: 'active', operator: '=', value: true }],
lookupFilters: [{ field: 'active', operator: '=', value: true }],
lookup_page_size: 25,
depends_on: ['org'],
});
Expand Down Expand Up @@ -221,7 +227,7 @@ describe("the reference-bearing rule is core's object, not a copy (objectui#5312
expect(
paramToField(p({ type, referenceTo: 'accounts', displayField: 'name' })),
`'${type}' lost its reference config in the convergence`,
).toMatchObject({ type, reference_to: 'accounts', display_field: 'name' });
).toMatchObject({ type, reference_to: 'accounts', displayField: 'name' });
}
expect(RETIRED_INLINE_MEMBERS.filter((t) => !EXPANDABLE_FIELD_TYPES.has(t))).toEqual([]);
});
Expand Down
8 changes: 4 additions & 4 deletions packages/app-shell/src/utils/paramToField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,12 +175,12 @@ export function paramToField(param: ActionParamDef): Record<string, any> {
if (EXPANDABLE_FIELD_TYPES.has(type)) {
Object.assign(field, {
reference_to: param.referenceTo,
display_field: param.displayField,
id_field: param.idField,
description_field: param.descriptionField,
displayField: param.displayField,
idField: param.idField,
descriptionField: param.descriptionField,
title_format: param.titleFormat,
lookup_columns: param.lookupColumns,
lookup_filters: param.lookupFilters,
lookupFilters: param.lookupFilters,
lookup_page_size: param.lookupPageSize,
depends_on: param.dependsOn,
});
Expand Down
5 changes: 3 additions & 2 deletions packages/app-shell/src/utils/resolveActionParams.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,9 @@ describe('resolveActionParams — authored through the public ActionParam type (
expect(authorToField(authored)).toMatchObject({
type: 'lookup',
reference_to: 'sys_user',
display_field: 'name',
id_field: 'id',
// objectui#7155 — the spec spelling, emitted by `paramToField`.
displayField: 'name',
idField: 'id',
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const state = vi.hoisted(() => {

// Static factories, matching AccessExplainPanel.test.tsx: app-shell tests stub
// `@object-ui/fields` and `@object-ui/react` rather than load their real
// graphs. LookupField's own behaviour (search, hydration through `id_field`,
// graphs. LookupField's own behaviour (search, hydration through `idField`,
// commit-on-select) is covered in the fields package — this suite verifies
// the WIRING: which cell renders per kind, and what binding it receives.
vi.mock('@object-ui/react', async (importOriginal) => ({
Expand All @@ -51,11 +51,11 @@ vi.mock('@object-ui/react', async (importOriginal) => ({
subscribeDataChanges: () => () => {},
}));
vi.mock('@object-ui/fields', () => ({
LookupField: (props: { field?: { reference_to?: string; id_field?: string; multiple?: boolean } }) => (
LookupField: (props: { field?: { reference_to?: string; idField?: string; multiple?: boolean } }) => (
<div
data-testid="record-lookup"
data-object={props.field?.reference_to}
data-value-field={props.field?.id_field}
data-value-field={props.field?.idField}
data-multiple={String(props.field?.multiple ?? false)}
/>
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -553,9 +553,9 @@ function RecordLookupCell({ binding, value, onPick, onCommit, onBlur, disabled,
type: 'lookup',
name: 'value',
reference_to: binding.object,
display_field: binding.displayField,
displayField: binding.displayField,
// `position` commits the machine name, the rest the row id.
id_field: binding.valueField,
idField: binding.valueField,
multiple: false,
// A directory row is never created from a flow-authoring picker.
allow_create: false,
Expand Down
22 changes: 11 additions & 11 deletions packages/fields/src/__tests__/lookupCellDisplayField.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* [framework#2926 ⑧] LookupCellRenderer must honor the target object's
* configured display field. ObjectGrid forwards `display_field` on the
* configured display field. ObjectGrid forwards `displayField` on the
* column meta (RELATIONAL_META_KEYS) exactly like `reference`, but the read
* cell used to ignore it and always ran the hardcoded heuristics — `name`
* first — so a target object whose displayNameField is a localized/label
Expand Down Expand Up @@ -29,14 +29,14 @@ function makeDataSource() {
return { findOne, find: vi.fn() } as any;
}

describe('LookupCellRenderer — display_field resolution', () => {
it('prefers the configured display_field over the heuristic `name`', async () => {
describe('LookupCellRenderer — displayField resolution', () => {
it('prefers the configured displayField over the heuristic `name`', async () => {
const ds = makeDataSource();
render(
<SchemaRendererProvider dataSource={ds}>
<LookupCellRenderer
value={ID_A}
field={{ type: 'lookup', reference: 'showcase_category', display_field: 'label_zh' } as any}
field={{ type: 'lookup', reference: 'showcase_category', displayField: 'label_zh' } as any}
/>
</SchemaRendererProvider>,
);
Expand All @@ -46,7 +46,7 @@ describe('LookupCellRenderer — display_field resolution', () => {
expect(screen.queryByText('cat_hardware')).not.toBeInTheDocument();
});

it('keeps the heuristic (`name` first) when no display_field is configured', async () => {
it('keeps the heuristic (`name` first) when no displayField is configured', async () => {
const ds = makeDataSource();
render(
<SchemaRendererProvider dataSource={ds}>
Expand All @@ -61,23 +61,23 @@ describe('LookupCellRenderer — display_field resolution', () => {
});
});

it('uses display_field on server-expanded nested objects (no fetch path)', () => {
it('uses displayField on server-expanded nested objects (no fetch path)', () => {
const ds = makeDataSource();
render(
<SchemaRendererProvider dataSource={ds}>
<LookupCellRenderer
value={{ id: ID_A, name: 'cat_hardware', label_zh: '硬件' } as any}
field={{ type: 'lookup', reference: 'showcase_category', display_field: 'label_zh' } as any}
field={{ type: 'lookup', reference: 'showcase_category', displayField: 'label_zh' } as any}
/>
</SchemaRendererProvider>,
);
expect(screen.getByText('硬件')).toBeInTheDocument();
expect(ds.findOne).not.toHaveBeenCalled();
});

it('does not serve a cached heuristic name to a display_field column (cache key isolation)', async () => {
it('does not serve a cached heuristic name to a displayField column (cache key isolation)', async () => {
const ds = makeDataSource();
// First: a column WITHOUT display_field resolves and caches the heuristic name.
// First: a column WITHOUT displayField resolves and caches the heuristic name.
const first = render(
<SchemaRendererProvider dataSource={ds}>
<LookupCellRenderer
Expand All @@ -90,13 +90,13 @@ describe('LookupCellRenderer — display_field resolution', () => {
expect(screen.getByText('cat_software')).toBeInTheDocument();
});
first.unmount();
// Then: a column WITH display_field for the same record must show the
// Then: a column WITH displayField for the same record must show the
// configured field, not the previously cached heuristic name.
render(
<SchemaRendererProvider dataSource={ds}>
<LookupCellRenderer
value={ID_B}
field={{ type: 'lookup', reference: 'showcase_category', display_field: 'label_zh' } as any}
field={{ type: 'lookup', reference: 'showcase_category', displayField: 'label_zh' } as any}
/>
</SchemaRendererProvider>,
);
Expand Down
2 changes: 1 addition & 1 deletion packages/fields/src/complex-widgets.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ describe('Complex & Relationship Widgets', () => {

const fieldWithDesc = {
...dynamicField,
description_field: 'industry',
descriptionField: 'industry',
} as any;

render(<LookupField {...dynamicProps} field={fieldWithDesc} />);
Expand Down
5 changes: 2 additions & 3 deletions packages/fields/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ function useLookupName(
(typeof value === 'string' || typeof value === 'number') &&
value !== '';
// The preferred display field is part of the cache identity: two columns
// targeting the same record with different `display_field`s must not
// targeting the same record with different `displayField`s must not
// serve each other's cached name (#2926 ⑧).
const cacheKey = isResolvable
? `${referenceTo}:${String(value)}:${displayField ?? ''}`
Expand Down Expand Up @@ -1845,10 +1845,9 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
(field as { reference?: string }).reference;

// Explicit author-chosen display field on the lookup — beats every resolver.
// ObjectGrid forwards `display_field` on the column meta (RELATIONAL_META_KEYS)
// ObjectGrid forwards `displayField` on the column meta (RELATIONAL_META_KEYS)
// the same way it forwards `reference` (#2926 ⑧).
const displayField =
(field as { display_field?: string }).display_field ||
(field as { displayField?: string }).displayField ||
(field as { reference_field?: string }).reference_field ||
undefined;
Expand Down
6 changes: 3 additions & 3 deletions packages/fields/src/widgets/GridField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -756,7 +756,7 @@ export function GridField({
value={row[c.name]}
onChange={() => {}}
readonly
field={{ reference: c.reference, display_field: c.displayField, id_field: c.idField } as any}
field={{ reference: c.reference, displayField: c.displayField, idField: c.idField } as any}
/>
) : c.type === 'file' || isTemporal(c.type) ? (
// A temporal column printed with `String(value)` puts
Expand Down Expand Up @@ -852,7 +852,7 @@ export function GridField({
if (c.type === 'lookup' && val != null && val !== '') {
return (
<LookupField value={val} onChange={() => {}} readonly
field={{ reference: c.reference, display_field: c.displayField, id_field: c.idField } as any} />
field={{ reference: c.reference, displayField: c.displayField, idField: c.idField } as any} />
);
}
return (
Expand Down Expand Up @@ -880,7 +880,7 @@ export function GridField({
onChange={(v: any) => setCellValue(rowIdx, c.name, v)}
onSelectRecord={(rec: any) => applyLookupSelection(rowIdx, c, rec)}
compact
field={{ reference: c.reference, display_field: c.displayField, id_field: c.idField, multiple: c.multiple, options: c.options, placeholder: '—' } as any}
field={{ reference: c.reference, displayField: c.displayField, idField: c.idField, multiple: c.multiple, options: c.options, placeholder: '—' } as any}
disabled={locked}
// The published `error` slot, not a hand-rolled attribute: LookupField
// already puts `aria-invalid` on its own focusable trigger from it.
Expand Down
10 changes: 5 additions & 5 deletions packages/fields/src/widgets/LookupField.idField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

/**
* Regression (objectstack #3508): a lookup whose committed value is NOT the
* primary id (`id_field: 'name'` — e.g. approval `position` approvers, which
* primary id (`idField: 'name'` — e.g. approval `position` approvers, which
* the engine routes by machine name) must hydrate its display label by
* FILTERING on that field. The old path always called `findOne(object, value)`
* — a primary-id GET — so a stored machine name never resolved and the field
Expand All @@ -16,8 +16,8 @@ import { LookupField } from './LookupField';

afterEach(cleanup);

describe('LookupField — id_field hydration (#3508)', () => {
it('hydrates by filtering on id_field when it is not the primary id', async () => {
describe('LookupField — idField hydration (#3508)', () => {
it('hydrates by filtering on idField when it is not the primary id', async () => {
const find = vi.fn(async () => ({
data: [{ id: 'pos_1', name: 'sales_manager', label: 'Sales Manager' }],
}));
Expand All @@ -29,8 +29,8 @@ describe('LookupField — id_field hydration (#3508)', () => {
dataSource={{ find, findOne } as never}
field={{
reference_to: 'sys_position',
id_field: 'name',
display_field: 'label',
idField: 'name',
displayField: 'label',
multiple: false,
} as never}
/>,
Expand Down
Loading
Loading