diff --git a/.changeset/7155-converge-lookup-dialect-camelcase.md b/.changeset/7155-converge-lookup-dialect-camelcase.md new file mode 100644 index 0000000000..da9add6337 --- /dev/null +++ b/.changeset/7155-converge-lookup-dialect-camelcase.md @@ -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. diff --git a/content/docs/fields/lookup.mdx b/content/docs/fields/lookup.mdx index 85b05d5609..52a59c699c 100644 --- a/content/docs/fields/lookup.mdx +++ b/content/docs/fields/lookup.mdx @@ -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 }], }; ``` @@ -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 } ``` @@ -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' }, @@ -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 diff --git a/content/docs/fields/user.mdx b/content/docs/fields/user.mdx index 3f07925833..10c7af0ed9 100644 --- a/content/docs/fields/user.mdx +++ b/content/docs/fields/user.mdx @@ -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 }], }; ``` diff --git a/packages/app-shell/src/utils/paramToField.test.ts b/packages/app-shell/src/utils/paramToField.test.ts index 3293bd0574..ac940bf7d8 100644 --- a/packages/app-shell/src/utils/paramToField.test.ts +++ b/packages/app-shell/src/utils/paramToField.test.ts @@ -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', @@ -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'], }); @@ -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([]); }); diff --git a/packages/app-shell/src/utils/paramToField.ts b/packages/app-shell/src/utils/paramToField.ts index 1800efd219..67e400880c 100644 --- a/packages/app-shell/src/utils/paramToField.ts +++ b/packages/app-shell/src/utils/paramToField.ts @@ -175,12 +175,12 @@ export function paramToField(param: ActionParamDef): Record { 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, }); diff --git a/packages/app-shell/src/utils/resolveActionParams.test.ts b/packages/app-shell/src/utils/resolveActionParams.test.ts index 0d61eec51a..04663bb3dd 100644 --- a/packages/app-shell/src/utils/resolveActionParams.test.ts +++ b/packages/app-shell/src/utils/resolveActionParams.test.ts @@ -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', }); }); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.lookup.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.lookup.test.tsx index fcbed3dfc5..b7265b3e9a 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.lookup.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.lookup.test.tsx @@ -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) => ({ @@ -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 } }) => (
), diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx index a44becc0a2..e4eef20cb2 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx @@ -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, diff --git a/packages/fields/src/__tests__/lookupCellDisplayField.test.tsx b/packages/fields/src/__tests__/lookupCellDisplayField.test.tsx index 7d68c70cd8..d1f883cae2 100644 --- a/packages/fields/src/__tests__/lookupCellDisplayField.test.tsx +++ b/packages/fields/src/__tests__/lookupCellDisplayField.test.tsx @@ -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 @@ -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( , ); @@ -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( @@ -61,13 +61,13 @@ 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( , ); @@ -75,9 +75,9 @@ describe('LookupCellRenderer — display_field resolution', () => { 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( { 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( , ); diff --git a/packages/fields/src/complex-widgets.test.tsx b/packages/fields/src/complex-widgets.test.tsx index 9f633053c7..d1b7f9e1e5 100644 --- a/packages/fields/src/complex-widgets.test.tsx +++ b/packages/fields/src/complex-widgets.test.tsx @@ -286,7 +286,7 @@ describe('Complex & Relationship Widgets', () => { const fieldWithDesc = { ...dynamicField, - description_field: 'industry', + descriptionField: 'industry', } as any; render(); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 593f7564e3..2bdf38de8a 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -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 ?? ''}` @@ -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; diff --git a/packages/fields/src/widgets/GridField.tsx b/packages/fields/src/widgets/GridField.tsx index 073d7f5fd3..4e63281b51 100644 --- a/packages/fields/src/widgets/GridField.tsx +++ b/packages/fields/src/widgets/GridField.tsx @@ -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 @@ -852,7 +852,7 @@ export function GridField({ if (c.type === 'lookup' && val != null && val !== '') { return ( {}} 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 ( @@ -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. diff --git a/packages/fields/src/widgets/LookupField.idField.test.tsx b/packages/fields/src/widgets/LookupField.idField.test.tsx index 836ccb8566..0ba4588e6e 100644 --- a/packages/fields/src/widgets/LookupField.idField.test.tsx +++ b/packages/fields/src/widgets/LookupField.idField.test.tsx @@ -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 @@ -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' }], })); @@ -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} />, diff --git a/packages/fields/src/widgets/LookupField.tsx b/packages/fields/src/widgets/LookupField.tsx index 6177a2554e..216112b4aa 100644 --- a/packages/fields/src/widgets/LookupField.tsx +++ b/packages/fields/src/widgets/LookupField.tsx @@ -44,7 +44,7 @@ export interface LookupOption { * declared `SelectOptionMetadata.description` (objectui#6153 — declared * there, and on `@objectstack/spec`'s `SelectOptionSchema`, precisely * because this widget consumes it); for fetched records `recordToOption` - * derives it from `description_field`. + * derives it from `descriptionField`. */ description?: string; [key: string]: any; @@ -243,7 +243,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro // The form renderer passes `field: field.field || field` — `.field` is the // declared metadata slot (objectui#3090) — so the actual objectSchema field - // metadata (reference_to, display_field, etc.) can arrive nested at + // metadata (reference_to, displayField, etc.) can arrive nested at // `lookupField.field`. Unwrap it so lookup-specific properties resolve // correctly. (This used to credit the docs-demo `createFieldRenderer` wrapper, // which never produced the nesting and was removed in objectui#3910; the form @@ -257,9 +257,9 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro const staticOptions: LookupOption[] = fieldMeta?.options || []; const multiple = fieldMeta?.multiple || false; - const displayField = fieldMeta?.display_field || fieldMeta?.displayField || fieldMeta?.reference_field || 'name'; - const descriptionField: string | undefined = fieldMeta?.description_field ?? fieldMeta?.descriptionField; - const idField = fieldMeta?.id_field || 'id'; + const displayField = fieldMeta?.displayField || fieldMeta?.reference_field || 'name'; + const descriptionField: string | undefined = fieldMeta?.descriptionField; + const idField = fieldMeta?.idField || 'id'; // ObjectStack convention uses `reference`; types define `reference_to` — support both const referenceTo: string | undefined = fieldMeta?.reference_to || fieldMeta?.reference; // Inline quick-create — a STANDARD capability, default ON for user-facing @@ -275,7 +275,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro // Enterprise Record Picker configuration const lookupColumns: Array | undefined = fieldMeta?.lookup_columns ?? fieldMeta?.lookupColumns; const lookupPageSize: number | undefined = fieldMeta?.lookup_page_size ?? fieldMeta?.lookupPageSize; - const lookupFilters: import('@object-ui/types').LookupFilterDef[] | undefined = fieldMeta?.lookup_filters ?? fieldMeta?.lookupFilters; + const lookupFilters: import('@object-ui/types').LookupFilterDef[] | undefined = fieldMeta?.lookupFilters; // Search-first PeoplePicker opt-in (user fields). When `picker === 'search'` // the Level-2 picker is the rich PeoplePicker (avatar rows + selection tray) @@ -421,7 +421,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro /** * Secondary line under each quick-select option. Honour explicit - * `description_field`; otherwise reuse the first derived non-display column so + * `descriptionField`; otherwise reuse the first derived non-display column so * the inline popover also benefits from the richer schema. */ const effectiveDescriptionField = useMemo(() => { @@ -442,7 +442,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro * formatted dates and option labels came out here as bare foreign-key ids, * ISO timestamps and enum codes. * - * Now one list feeds one renderer: an explicitly authored `description_field` + * Now one list feeds one renderer: an explicitly authored `descriptionField` * still leads (the author picked that column to be the subtitle), followed by * every non-display picker column. Both halves render through * `renderLookupColumnValue`, the picker's own renderer. @@ -613,7 +613,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro const fetched: LookupOption[] = []; // Single id: the pre-existing cheap paths — a primary-id `findOne` // GET, or an equality filter when the field commits a different - // column (`id_field: 'name'` — e.g. position machine names, + // column (`idField: 'name'` — e.g. position machine names, // objectstack #3508). if (unresolved.length === 1) { const id = unresolved[0]; diff --git a/packages/fields/src/widgets/RecordPickerDialog.tsx b/packages/fields/src/widgets/RecordPickerDialog.tsx index 0585e7d82f..123a3efa22 100644 --- a/packages/fields/src/widgets/RecordPickerDialog.tsx +++ b/packages/fields/src/widgets/RecordPickerDialog.tsx @@ -187,7 +187,7 @@ export function lookupFiltersToRecord( * Radix `Select` speaks strings only: an option renders as * `value={String(opt.value)}` and `onValueChange` hands that same string back. * A filter option's value, however, is whatever the metadata author wrote — - * `lookup_filters: [{ field: 'level', operator: 'in', value: [1, 2, 3] }]` + * `lookupFilters: [{ field: 'level', operator: 'in', value: [1, 2, 3] }]` * derives options with NUMBER values (`LookupFilterDef.value` is `unknown`) — * so writing the control's string straight into `$filter` queried * `{ level: "1" }` against records storing `level: 1`, and the panel returned @@ -314,7 +314,7 @@ export interface RecordPickerDialogProps { /** * Base filters applied to every query. - * Converted from LookupFieldMetadata.lookup_filters. + * Converted from LookupFieldMetadata.lookupFilters. * Restricts which records are selectable (e.g. only active records). */ lookupFilters?: LookupFilterDef[]; @@ -561,7 +561,7 @@ export function RecordPickerDialog({ }); }, [baseFilterColumns, fieldsMeta, objectName, translateOptions]); - // Merge base lookup_filters with user filter bar values, then apply the hard + // Merge base lookupFilters with user filter bar values, then apply the hard // `baseFilter` constraint BY SHAPE (see the prop's own doc for why the two // shapes exist): // diff --git a/packages/fields/src/widgets/UserField.tsx b/packages/fields/src/widgets/UserField.tsx index 121cbd6894..019158e2ba 100644 --- a/packages/fields/src/widgets/UserField.tsx +++ b/packages/fields/src/widgets/UserField.tsx @@ -46,11 +46,11 @@ export function UserField(props: FieldWidgetComponentProps) { const normalized = { ...(meta || {}), reference: meta?.reference || meta?.reference_to || 'sys_user', - display_field: meta?.display_field || meta?.displayField || meta?.reference_field || 'name', + displayField: meta?.displayField || meta?.reference_field || 'name', picker: meta?.picker ?? 'search', subtitle: meta?.subtitle ?? ['primary_business_unit_id.name', 'email'], avatar_field: meta?.avatar_field ?? meta?.avatarField ?? 'image', - lookup_filters: withBannedFilter(meta?.lookup_filters ?? meta?.lookupFilters), + lookupFilters: withBannedFilter(meta?.lookupFilters), }; const fieldProp = metaIsNested ? { ...raw, field: normalized } : normalized; diff --git a/packages/fields/src/widgets/useRecordQuery.ts b/packages/fields/src/widgets/useRecordQuery.ts index ddb37ad426..698cbf8059 100644 --- a/packages/fields/src/widgets/useRecordQuery.ts +++ b/packages/fields/src/widgets/useRecordQuery.ts @@ -53,7 +53,7 @@ export interface UseRecordQueryOptions { */ paginate?: boolean; /** - * `$filter` — already merged by the caller (base `lookup_filters`, dependent + * `$filter` — already merged by the caller (base `lookupFilters`, dependent * lookup chain, candidate hygiene like `banned != true`, …). Compared by * value, so a referentially-new-but-equal object each render will not loop. * diff --git a/packages/plugin-detail/src/__tests__/DetailSection.multiValueInlineEdit.test.tsx b/packages/plugin-detail/src/__tests__/DetailSection.multiValueInlineEdit.test.tsx index 2886db36ee..b269f6270f 100644 --- a/packages/plugin-detail/src/__tests__/DetailSection.multiValueInlineEdit.test.tsx +++ b/packages/plugin-detail/src/__tests__/DetailSection.multiValueInlineEdit.test.tsx @@ -40,7 +40,9 @@ const objectSchema = { tags: { type: 'lookup', reference: 'tags', - display_field: 'name', + // objectui#7155 — the spec spelling; `display_field` is no longer + // enriched onto the inline-edit field meta. + displayField: 'name', multiple: true, }, priority: { diff --git a/packages/plugin-detail/src/__tests__/fieldEnrichment.test.ts b/packages/plugin-detail/src/__tests__/fieldEnrichment.test.ts index 64a1dcb6f0..9f0c695bf0 100644 --- a/packages/plugin-detail/src/__tests__/fieldEnrichment.test.ts +++ b/packages/plugin-detail/src/__tests__/fieldEnrichment.test.ts @@ -17,9 +17,9 @@ describe('enrichDetailField', () => { type: 'lookup', reference: 'tags', multiple: true, - display_field: 'name', - id_field: 'code', - lookup_filters: [{ field: 'active', operator: 'eq', value: true }], + displayField: 'name', + idField: 'code', + lookupFilters: [{ field: 'active', operator: 'eq', value: true }], depends_on: ['category'], picker: 'search', }, @@ -30,12 +30,12 @@ describe('enrichDetailField', () => { // ObjectStack's `reference` spelling normalizes onto the canonical key. reference_to: 'tags', multiple: true, - display_field: 'name', - id_field: 'code', + displayField: 'name', + idField: 'code', picker: 'search', depends_on: ['category'], }); - expect(enriched.lookup_filters).toEqual([{ field: 'active', operator: 'eq', value: true }]); + expect(enriched.lookupFilters).toEqual([{ field: 'active', operator: 'eq', value: true }]); }); it('keeps the view field as the override — the schema only fills gaps', () => { diff --git a/packages/plugin-detail/src/fieldEnrichment.ts b/packages/plugin-detail/src/fieldEnrichment.ts index 5d66ad3fa1..0680501cee 100644 --- a/packages/plugin-detail/src/fieldEnrichment.ts +++ b/packages/plugin-detail/src/fieldEnrichment.ts @@ -178,26 +178,26 @@ export const ENRICHED_FIELD_METADATA_KEYS = [ 'dueLike', // Relational / picker configuration. Every key the lookup + user pickers read - // off their field metadata (both the ObjectStack snake_case convention and the - // camelCase alias each reader accepts), so an inline-edit picker behaves + // off their field metadata, so an inline-edit picker behaves // exactly like the same field on the object form: multi-value selection, // display/description/id fields, quick-create, the Level-2 record picker's // columns / page size / base filters, the search-first people picker, and // dependent-lookup gating. 'multiple', 'reference_field', - 'display_field', + // ⭐ objectui#7155 converged the lookup dialect on the spec's camelCase and + // retired `display_field` / `description_field` / `id_field` / `lookup_filters` + // from the widget contract, the docs and every in-repo producer. Copying them + // here would forward keys no reader consults. 'displayField', - 'description_field', 'descriptionField', - 'id_field', + 'idField', 'allow_create', 'allowCreate', 'lookup_columns', 'lookupColumns', 'lookup_page_size', 'lookupPageSize', - 'lookup_filters', 'lookupFilters', 'picker', 'subtitle', diff --git a/packages/plugin-grid/src/__tests__/bulkParamToField.test.ts b/packages/plugin-grid/src/__tests__/bulkParamToField.test.ts index 93ea079016..9724f632d7 100644 --- a/packages/plugin-grid/src/__tests__/bulkParamToField.test.ts +++ b/packages/plugin-grid/src/__tests__/bulkParamToField.test.ts @@ -53,7 +53,7 @@ describe('bulkParamToField', () => { type: 'lookup', required: true, reference_to: 'queues', - display_field: 'title', + displayField: 'title', multiple: false, }); }); @@ -148,12 +148,12 @@ describe('the data-source rule is core\'s object, not a copy (objectui#4815)', ( // only the one under test would leave the others forked. `isLookupishParam` // (label prefetch + option source in BulkActionDialog), `fieldNeedsDataSource` // (the `dataSource` prop the dialog threads into the widget) and - // `bulkParamToField`'s `reference_to` / `display_field` branch must each + // `bulkParamToField`'s `reference_to` / `displayField` branch must each // reach core. const consumers: [string, string, () => unknown][] = [ ['isLookupishParam', 'user', () => isLookupishParam({ name: 'u', type: 'user' })], ['fieldNeedsDataSource', 'user', () => fieldNeedsDataSource({ type: 'user' })], - // The `reference_to` / `display_field` branch, reached with the widget key + // The `reference_to` / `displayField` branch, reached with the widget key // this param resolves to. ['bulkParamToField', 'lookup', () => bulkParamToField({ name: 'q', type: 'lookup', object: 'queues' }, false)], ]; diff --git a/packages/plugin-grid/src/__tests__/lookupDisplayFieldSpelling-6875.test.tsx b/packages/plugin-grid/src/__tests__/lookupDisplayFieldSpelling-6875.test.tsx index 386e634b1b..f0e6b33239 100644 --- a/packages/plugin-grid/src/__tests__/lookupDisplayFieldSpelling-6875.test.tsx +++ b/packages/plugin-grid/src/__tests__/lookupDisplayFieldSpelling-6875.test.tsx @@ -10,6 +10,21 @@ * objectui#6875 — a lookup cell in `ObjectGrid` must honour the author's * `displayField`, the SPEC-DECLARED spelling. * + * ⭐ objectui#7155 INVERTED the second half of this pin. It used to assert + * PARITY: both spellings resolve to the same value. That parity was the visible + * face of a two-dialect seam — `@objectstack/spec` declared camelCase and + * refused snake_case, while `@object-ui/types`' widget metadata declared + * snake_case and refused camelCase, and the read chains served both with the + * snake leg FIRST. The maintainer ruled to converge the contracts on the spec's + * spelling in one payment, so the snake leg is gone from the chains, the + * published type, the docs and all seven in-repo producers. + * + * ⇒ This file now pins the REFUSAL: a def carrying `display_field` resolves to + * the generic name heuristic (the key is unread), and a def carrying + * `displayField` resolves to the author's pointer. ⛔ Do not "fix" the snake + * column back to green — that would restore the seam and make an undeclared + * key outrank a declared one. + * * ## Why this file renders a cell instead of asserting a key list * * The sibling pins in this directory (`relationalMetaCopySet-6711` / @@ -41,20 +56,24 @@ * `display_field || displayField || reference_field`, so it was ready for the * key the whole time; the value simply never arrived. * - * ## The control that makes the red half a reading + * ## The control that makes the refusal a reading * * Two columns render from ONE data source, ONE referenced record and ONE cell * renderer, differing only in the spelling on the field def: * - * `code_camel` → `{ displayField: 'project_code' }` (spec-declared) - * `code_snake` → `{ display_field: 'project_code' }` (already copied) + * `code_camel` → `{ displayField: 'project_code' }` (spec-declared, READ) + * `code_snake` → `{ display_field: 'project_code' }` (retired, UNREAD) * - * The snake column is the positive control. Before the fix it resolved - * `ACME-42` while the camel column resolved `Wrong Name` — the referenced - * record's `name`, via the generic heuristic that runs when no display field is - * declared. A single-column test could not tell "the key never arrived" apart - * from "the fixture never reached the lookup path at all"; the control column - * is what separates them, and it must stay green in both directions. + * ⚠️ The roles have swapped. The CAMEL column is now the positive control: it + * must resolve `ACME-42`, which proves the lookup path is reached and the + * resolver is returning a value at all. Without it, "the snake column does not + * show `ACME-42`" would be satisfied by a fixture that never rendered a lookup + * — the exact failure this file's original control existed to rule out. + * + * And the snake column's refusal is asserted POSITIVELY, by what it resolves TO + * (`Wrong Name`, the referenced record's `name` via the generic heuristic), not + * merely by the absence of `ACME-42`. A cell that rendered nothing at all would + * pass an absence-only assertion; it fails this one. */ import { describe, it, expect, vi, beforeAll } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; @@ -97,9 +116,10 @@ function makeDataSource() { fields: { id: { type: 'text' }, title: { type: 'text', label: 'Title' }, - // Spec-declared spelling. This is what a live `getObjectSchema` can carry. + // Spec-declared spelling — the only one read since objectui#7155. code_camel: { type: 'lookup', label: 'Project (spec spelling)', reference: REFERENCED, displayField: 'project_code' }, - // Runtime spelling, already in the copy set — the positive control. + // Retired snake_case spelling. Authored here on purpose: this column + // must DEGRADE to the generic heuristic, proving the leg is gone. code_snake: { type: 'lookup', label: 'Project (runtime spelling)', reference: REFERENCED, display_field: 'project_code' }, }, }; @@ -139,20 +159,28 @@ async function renderGrid() { } describe('objectui#6875 — ObjectGrid lookup cells honour the spec-declared `displayField`', () => { - it('the runtime spelling `display_field` resolves the declared display value (CONTROL)', async () => { + it('the spec spelling `displayField` resolves the declared display value (CONTROL)', async () => { await renderGrid(); await waitFor(() => { expect(screen.getAllByText('ACME-42').length).toBeGreaterThan(0); }, { timeout: 4000 }); }); - it('the spec spelling `displayField` resolves it too, and the row never shows the referenced record’s `name`', async () => { + it('⛔ objectui#7155 — the retired `display_field` is UNREAD: exactly one column resolves, and the snake column falls back to the generic name heuristic', async () => { await renderGrid(); + // CONTROL first: the camel column resolves, so the lookup path is reached + // and the resolver is returning values. Only then is the snake column's + // behaviour a measurement rather than an empty render. + await waitFor(() => { + expect(screen.getAllByText('ACME-42').length).toBeGreaterThan(0); + }, { timeout: 4000 }); + + // ⭐ The refusal, asserted by what the snake column resolves TO. Before + // objectui#7155 this was 2 (both spellings read); now the retired spelling + // reaches nothing and its cell degrades to the referenced record's `name`. await waitFor(() => { - // Both columns resolved through the author's pointer. - expect(screen.getAllByText('ACME-42').length).toBe(2); + expect(screen.getByText('Wrong Name')).toBeInTheDocument(); }, { timeout: 4000 }); - // The generic `.name` heuristic must not surface anywhere in the row. - expect(screen.queryByText('Wrong Name')).not.toBeInTheDocument(); + expect(screen.getAllByText('ACME-42').length).toBe(1); }); }); diff --git a/packages/plugin-grid/src/__tests__/lookupPickerKeys-7154.test.tsx b/packages/plugin-grid/src/__tests__/lookupPickerKeys-7154.test.tsx index 80d87f816f..33bdefe460 100644 --- a/packages/plugin-grid/src/__tests__/lookupPickerKeys-7154.test.tsx +++ b/packages/plugin-grid/src/__tests__/lookupPickerKeys-7154.test.tsx @@ -191,7 +191,8 @@ describe('objectui#7154 — the four picker keys reach the grid’s inline picke it('none of the four is on the relational copy set (the premise this file re-measures)', () => { // Control: the copy set is populated and holds the key objectui#6875 added, // so "does not contain" below is a reading and not an empty list. - expect(RELATIONAL_META_KEYS.length).toBeGreaterThan(5); + // objectui#7155 shrank it from 7 to 3 by retiring the snake_case dialect. + expect(RELATIONAL_META_KEYS.length).toBeGreaterThan(2); expect(RELATIONAL_META_KEYS).toContain('displayField'); for (const key of ['multiple', 'allowCreate', 'lookupPageSize', 'dependsOn']) { expect(RELATIONAL_META_KEYS).not.toContain(key); diff --git a/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6711.test.tsx b/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6711.test.tsx index 50cbe6f030..fdc180ea1c 100644 --- a/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6711.test.tsx +++ b/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6711.test.tsx @@ -15,7 +15,7 @@ * * ## The control against vacuity lives in the same assertions * - * Each case also asserts that the seven SURVIVING keys do arrive on the same + * Each case also asserts that the SURVIVING keys do arrive on the same * meta. An absence assertion on its own passes for the wrong reason as soon as * the fixture stops reaching the copy path at all (a renamed helper, a column * path that no longer resolves this renderer, a def the grid never reads); the @@ -55,9 +55,14 @@ const MANAGER_DEF = { label: 'Manager', reference_to: 'users', reference: 'users', - display_field: 'name', - id_field: 'id', - description_field: 'title', + // The spec spelling — the only display pointer read since objectui#7155. + displayField: 'name', + // ⭐ The snake_case dialect objectui#7155 RETIRED. Kept on the fixture on + // purpose, exactly like `reference_to_field` below: their absence from the + // copied meta is then a reading, not a fixture that never offered them. + display_field: 'MUST_NOT_BE_COPIED', + id_field: 'MUST_NOT_BE_COPIED', + description_field: 'MUST_NOT_BE_COPIED', lookup_filters: [['active', '=', true]], lookupFilters: [['active', '=', true]], // Also retired, in objectui#6874, and pinned in its own file @@ -68,11 +73,20 @@ const MANAGER_DEF = { reference_to_field: 'MUST_NOT_BE_COPIED', }; -/** The six keys that survive every retirement so far — the control. */ +/** + * The keys that survive every retirement so far — the control. + * + * ⭐ objectui#7155 shrank this from six to three. It converged the lookup + * dialect on the spec's camelCase, so `display_field` / `id_field` / + * `description_field` / `lookup_filters` are no longer copied — `displayField` + * carries the display pointer now, and the other three were already off the + * copy set (objectui#7166) under their snake spellings only. + * + * ⛔ Keep this list non-empty and keep asserting it: it is what separates "the + * retirement removed exactly its key" from "the copy stopped working". + */ const SURVIVING_KEYS = [ - 'reference_to', 'reference', - 'display_field', 'id_field', 'description_field', - 'lookup_filters', + 'reference_to', 'reference', 'displayField', ] as const; const ROWS = [{ id: 'r1', name: 'Tower T1', manager: 'u1' }]; @@ -161,18 +175,23 @@ describe('objectui#6711 — ObjectGrid no longer copies `reference_to_field` ont expect(meta).not.toHaveProperty('reference_to_field'); }); - it(`still copies the six surviving relational keys (${name})`, async () => { + it(`still copies the surviving relational keys (${name})`, async () => { const meta = await renderAndCaptureMeta(schemaExtra); for (const key of SURVIVING_KEYS) { expect(meta).toHaveProperty(key); } expect(meta.reference_to).toBe('users'); - expect(meta.display_field).toBe('name'); + expect(meta.displayField).toBe('name'); // objectui#7166 retired `lookupFilters` from the copy set — its only // reader is an editor widget, which `renderCellEditor` feeds from the // schema def. The fixture above still declares it, so this absence is a // reading and not a fixture that never offered the key. expect(meta).not.toHaveProperty('lookupFilters'); + // ⭐ objectui#7155 — the retired snake_case dialect is not copied either. + // The fixture declares all four, so each absence is a reading. + for (const retired of ['display_field', 'id_field', 'description_field', 'lookup_filters']) { + expect(meta, `${retired} is copied again — objectui#7155 retired it`).not.toHaveProperty(retired); + } }); } }); diff --git a/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6874.test.tsx b/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6874.test.tsx index b07e99333c..1b4ee8fd14 100644 --- a/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6874.test.tsx +++ b/packages/plugin-grid/src/__tests__/relationalMetaCopySet-6874.test.tsx @@ -23,7 +23,7 @@ * * ## The control against vacuity lives in the same assertions * - * Each case also asserts that the seven SURVIVING keys do arrive on the same + * Each case also asserts that the SURVIVING keys do arrive on the same * meta. An absence assertion on its own passes for the wrong reason as soon as * the fixture stops reaching the copy path at all (a renamed helper, a column * path that no longer resolves this renderer, a def the grid never reads); the @@ -67,9 +67,14 @@ const MANAGER_DEF = { label: 'Manager', reference_to: 'users', reference: 'users', - display_field: 'name', - id_field: 'id', - description_field: 'title', + // The spec spelling — the only display pointer read since objectui#7155. + displayField: 'name', + // ⭐ The snake_case dialect objectui#7155 RETIRED. Kept on the fixture on + // purpose, exactly like `reference_to_field` below: their absence from the + // copied meta is then a reading, not a fixture that never offered them. + display_field: 'MUST_NOT_BE_COPIED', + id_field: 'MUST_NOT_BE_COPIED', + description_field: 'MUST_NOT_BE_COPIED', lookup_filters: [['active', '=', true]], lookupFilters: [['active', '=', true]], // The key this file pins as retired (objectui#6874). Kept on the fixture on @@ -79,11 +84,20 @@ const MANAGER_DEF = { reference_to_field: 'x', }; -/** The six keys that survive every retirement so far — the control. */ +/** + * The keys that survive every retirement so far — the control. + * + * ⭐ objectui#7155 shrank this from six to three. It converged the lookup + * dialect on the spec's camelCase, so `display_field` / `id_field` / + * `description_field` / `lookup_filters` are no longer copied — `displayField` + * carries the display pointer now, and the other three were already off the + * copy set (objectui#7166) under their snake spellings only. + * + * ⛔ Keep this list non-empty and keep asserting it: it is what separates "the + * retirement removed exactly its key" from "the copy stopped working". + */ const SURVIVING_KEYS = [ - 'reference_to', 'reference', - 'display_field', 'id_field', 'description_field', - 'lookup_filters', + 'reference_to', 'reference', 'displayField', ] as const; const ROWS = [{ id: 'r1', name: 'Tower T1', manager: 'u1' }]; @@ -172,18 +186,23 @@ describe('objectui#6874 — ObjectGrid no longer copies `titleFormat` onto field expect(meta).not.toHaveProperty('titleFormat'); }); - it(`still copies the six surviving relational keys (${name})`, async () => { + it(`still copies the surviving relational keys (${name})`, async () => { const meta = await renderAndCaptureMeta(schemaExtra); for (const key of SURVIVING_KEYS) { expect(meta).toHaveProperty(key); } expect(meta.reference_to).toBe('users'); - expect(meta.display_field).toBe('name'); + expect(meta.displayField).toBe('name'); // objectui#7166 retired `lookupFilters` from the copy set — its only // reader is an editor widget, which `renderCellEditor` feeds from the // schema def. The fixture above still declares it, so this absence is a // reading and not a fixture that never offered the key. expect(meta).not.toHaveProperty('lookupFilters'); + // ⭐ objectui#7155 — the retired snake_case dialect is not copied either. + // The fixture declares all four, so each absence is a reading. + for (const retired of ['display_field', 'id_field', 'description_field', 'lookup_filters']) { + expect(meta, `${retired} is copied again — objectui#7155 retired it`).not.toHaveProperty(retired); + } }); } }); diff --git a/packages/plugin-grid/src/__tests__/relationalMetaCopySet-7166.test.tsx b/packages/plugin-grid/src/__tests__/relationalMetaCopySet-7166.test.tsx index e68e56ff32..7798aca60a 100644 --- a/packages/plugin-grid/src/__tests__/relationalMetaCopySet-7166.test.tsx +++ b/packages/plugin-grid/src/__tests__/relationalMetaCopySet-7166.test.tsx @@ -159,16 +159,21 @@ describe('objectui#7166 — the three retired keys were never delivered by this it('none of the three is on the copy set any more (the premise the rest of this file measures)', () => { // Control: the copy set is populated and still holds the key objectui#6875 // genuinely delivered, so "does not contain" below is a reading. - expect(RELATIONAL_META_KEYS.length).toBeGreaterThan(5); + // ⚠️ objectui#7155 shrank the copy set from 7 to 3 (the snake_case dialect + // was retired), so this floor moved with it. It is still a floor, not a + // formality: at 0 every `not.toContain` below would pass vacuously. + expect(RELATIONAL_META_KEYS.length).toBeGreaterThan(2); expect(RELATIONAL_META_KEYS).toContain('displayField'); for (const key of ['descriptionField', 'lookupColumns', 'lookupFilters']) { expect(RELATIONAL_META_KEYS).not.toContain(key); } - // The snake_case legacy aliases are a DIFFERENT population and stay copied: - // their retention rests on an open producer-side question, which this - // card's reader-side measurement does not touch. - for (const key of ['description_field', 'lookup_filters', 'id_field']) { - expect(RELATIONAL_META_KEYS).toContain(key); + // ⭐ objectui#7155 — INVERTED. These snake_case spellings used to be a + // DIFFERENT population that stayed copied, on an open producer-side + // question. That question was answered (the host feeding snake_case was + // this repo's own widget contract and docs) and the dialect was retired, so + // they are now absent for the same reason as the camel three above. + for (const key of ['description_field', 'lookup_filters', 'id_field', 'display_field']) { + expect(RELATIONAL_META_KEYS).not.toContain(key); } }); diff --git a/packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts b/packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts index 613bfd772b..e4a0924c66 100644 --- a/packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts +++ b/packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts @@ -49,9 +49,9 @@ * * - as `spec` / `adapter-stamped` — the cell does not read them, so the * derived copy set does not contain them and the copy-set assertion is red; - * - as `legacy-alias` — that exit needs `copiedWithoutCellReader`, which is - * confined to keys `FieldSchema` does NOT declare, and all three are - * spec-declared. + * - under the `copiedWithoutCellReader` exit — objectui#7155 retired the + * `legacy-alias` class that exit existed for, and the gate now asserts NO + * entry takes it at all. * * ## The three consumers, and how each is read * @@ -198,8 +198,16 @@ function declaredReaders(consumer: RelationalMetaConsumer): string[] { * whatever the copy set says. */ function assertExtractorFoundKnownChains(x: Extraction): void { - expect(x.cell).toContain('display_field'); + // ⭐ objectui#7155 retired the snake_case lookup dialect, so the cell's own + // chain is no longer a two-spelling pair. The control keeps its SHAPE — a + // consumer whose reads include both a camel and a snake spelling — by moving + // to `lookup-editor`, whose `lookup_columns` / `lookupColumns` pair this card + // did NOT touch. ⛔ Never weaken this to camel-only: the extractor's job is to + // see every spelling a consumer reads, and a control that only ever looks for + // the surviving dialect cannot detect an extractor that stopped seeing the + // other one. expect(x.cell).toContain('displayField'); + expect(x.cell).toContain('reference_to'); expect(x['lookup-editor']).toContain('lookup_columns'); expect(x['lookup-editor']).toContain('lookupColumns'); expect(x['user-editor']).toContain('reference_field'); @@ -306,14 +314,23 @@ describe('objectui#6875 — the copy set is derived from the consumers, not rest } }); - it('records the `legacy-alias` asymmetry mechanically — none of them is authorable', () => { - // These are copied for back-compat and cannot be produced by a - // spec-compliant author. Asserting it here keeps the docblock's claim from - // going stale silently if a future spec version declares one of them — at - // which point the verdict should become `spec`. - for (const [key, e] of Object.entries(RELATIONAL_META_READ_SET)) { - if (e.verdict !== 'legacy-alias') continue; - expect(specProps.has(key), `${key} is now spec-declared — reclassify it as 'spec'`).toBe(false); + it('proves the `widget-contract` verdict is exactly that — read, produced in-repo, and absent from the spec', () => { + const claimed = Object.entries(RELATIONAL_META_READ_SET) + .filter(([, e]) => e.verdict === 'widget-contract') + .map(([k]) => k); + // Control: the bucket is populated (objectui#7155 put `idField` in it), so + // the assertions below are readings and not an empty loop. + expect(claimed).toEqual(['idField']); + expect(specProps.size).toBeGreaterThan(60); + expect(specProps.has('displayField')).toBe(true); + for (const key of claimed) { + // If a future spec version declares one of these, it is no longer a + // widget-contract key — reclassify it as `spec` rather than keeping a + // second home for a spelling the object contract now owns. + expect( + specProps.has(key), + `${key} is now declared on FieldSchema — reclassify it as 'spec'`, + ).toBe(false); } }); @@ -328,7 +345,7 @@ describe('objectui#6875 — the copy set is derived from the consumers, not rest // independent routes, and a hand-edited `readers` cannot carry both. const expected = Object.entries(RELATIONAL_META_READ_SET) .filter(([key, e]) => - (e.verdict === 'spec' || e.verdict === 'adapter-stamped' || e.verdict === 'legacy-alias') + (e.verdict === 'spec' || e.verdict === 'adapter-stamped' || e.verdict === 'widget-contract') && (cellRead.has(key) || e.copiedWithoutCellReader !== undefined)) .map(([k]) => k); expect([...RELATIONAL_META_KEYS].sort()).toEqual(expected.sort()); @@ -343,32 +360,68 @@ describe('objectui#6875 — the copy set is derived from the consumers, not rest } }); - it('⭐ objectui#7187 — the one exit from the cell-reader rule names itself, and only a non-authorable key may take it', () => { - const exits = Object.entries(RELATIONAL_META_READ_SET).filter(([, e]) => e.copiedWithoutCellReader !== undefined); - // Control: the bucket is populated, so the loop below is a reading. - expect(exits.length).toBeGreaterThan(0); - for (const [key, e] of exits) { + it('⭐ objectui#7155 — the cell-reader rule has NO exit any more: the class it served is retired', () => { + // objectui#7187 gave the exit to the `legacy-alias` keys, which were kept + // on an UNANSWERED producer question. objectui#7155 answered it — the host + // feeding snake_case was this repo's own widget contract and docs — and + // converged both dialects on the spec spelling. So the exit is now unused. + // + // ⚠️ Control FIRST: the table is populated and the field is still + // reachable, so "no entry takes the exit" is a measurement and not an + // assertion over an empty or misresolved object. + const entries = Object.entries(RELATIONAL_META_READ_SET); + expect(entries.length).toBeGreaterThan(10); + expect(entries.some(([, e]) => 'copiedWithoutCellReader' in e || e.copiedWithoutCellReader === undefined)).toBe(true); + + const exits = entries.filter(([, e]) => e.copiedWithoutCellReader !== undefined).map(([k]) => k); + expect( + exits, + 'A key is copied onto this bag although no consumer fed it reads the key. That exit existed ' + + 'ONLY for the snake_case dialect objectui#7155 retired. Re-opening it needs a new ruling and ' + + 'a restatement of this gate — not a new flag.', + ).toEqual([]); + }); + + it('⛔ objectui#7155 — the retired snake_case dialect reaches no consumer and no verdict', () => { + const retiredDialect = ['display_field', 'description_field', 'lookup_filters', 'id_field']; + const x = extractReadSet(); + // ⚠️ Control FIRST — this whole test is a set of ABSENCE claims, and an + // extractor that found nothing would satisfy every one of them. These are + // the surviving spellings of the very same four concepts. + assertExtractorFoundKnownChains(x); + for (const survivor of ['displayField', 'descriptionField', 'lookupFilters', 'idField']) { expect( - e.verdict, - `${key} is copied without a cell reader under verdict '${e.verdict}'. That exit exists for the ` - + 'snake_case runtime spellings kept on an unanswered PRODUCER question, and nothing else — ' - + 'a spec-declared key taking it would be objectui#6875 happening again.', - ).toBe('legacy-alias'); - expect(specProps.has(key), `${key} is spec-declared — it cannot rest on "no producer can be surveyed"`).toBe(false); - expect(e.copiedWithoutCellReader!.length, `${key}'s exit has no reason`).toBeGreaterThan(20); + survivor in RELATIONAL_META_READ_SET, + `${survivor} is missing from the table — the control for this test is dark, so its absence ` + + 'claims below prove nothing.', + ).toBe(true); } - // ⛔ And no stale flags: the exit is only legal where it is actually needed. - const cellRead = extractReadSet().cell; - for (const [key, e] of Object.entries(RELATIONAL_META_READ_SET)) { - if (e.copiedWithoutCellReader === undefined) continue; - expect(cellRead.has(key), `${key} IS read by the cell — it does not need the exit; drop the field`).toBe(false); + // The camel survivors ARE read: `displayField` by the cell, the other three + // by the editor widgets that the schema spread feeds. + expect(x.cell).toContain('displayField'); + expect(x['lookup-editor']).toContain('idField'); + + for (const key of retiredDialect) { + expect(key in RELATIONAL_META_READ_SET, `${key} is back on the table — the dialect is retired`).toBe(false); + expect(RELATIONAL_META_KEYS, `${key} is back in the copy set`).not.toContain(key); + for (const consumer of CONSUMERS) { + expect( + x[consumer].has(key), + `${CONSUMER_SOURCE[consumer]} reads \`${key}\` again. objectui#7155 converged the widget ` + + 'contract on the spec spelling; re-adding the snake leg would restore the two-dialect seam ' + + 'and make an UNDECLARED key outrank a declared one.', + ).toBe(false); + } } + // ⛔ And the verdict that licensed them is unrepresentable, not merely unused. + expect(Object.values(RELATIONAL_META_READ_SET).map((e) => e.verdict)).not.toContain('legacy-alias'); }); it('⛔ objectui#7166 — the three retired keys stay OUT of the copy set, and objectui#7187 makes that DERIVED', () => { const retired = ['descriptionField', 'lookupColumns', 'lookupFilters']; // Control: the copy set is populated, so "not contained" is a reading. - expect(RELATIONAL_META_KEYS.length).toBeGreaterThan(5); + // objectui#7155 shrank it from 7 to 3 by retiring the snake_case dialect. + expect(RELATIONAL_META_KEYS.length).toBeGreaterThan(2); expect(RELATIONAL_META_KEYS).toContain('displayField'); for (const key of retired) { expect( diff --git a/packages/plugin-grid/src/components/bulkParamToField.ts b/packages/plugin-grid/src/components/bulkParamToField.ts index c7b6e1db1d..5f6c43d6ca 100644 --- a/packages/plugin-grid/src/components/bulkParamToField.ts +++ b/packages/plugin-grid/src/components/bulkParamToField.ts @@ -54,7 +54,7 @@ const USER_WIDGET_TYPES = new Set(['user']); /** * Whether the widget rendered for this key has to QUERY records to do its job — * so it must be handed the grid's `DataSource`, and its field shape needs the - * `reference_to` / `display_field` the picker queries with. + * `reference_to` / `displayField` the picker queries with. * * The reference-bearing half is NOT restated here: it is `EXPANDABLE_FIELD_TYPES` * from `@object-ui/core`, the one relational-field family the `$expand` builder @@ -178,7 +178,7 @@ export function bulkParamToField( if (widgetNeedsDataSource(type)) { field.reference_to = object; - if (typeof labelField === 'string') field.display_field = labelField; + if (typeof labelField === 'string') field.displayField = labelField; } return field; diff --git a/packages/plugin-grid/src/relationalMetaKeys.ts b/packages/plugin-grid/src/relationalMetaKeys.ts index bd4f805756..43a8139859 100644 --- a/packages/plugin-grid/src/relationalMetaKeys.ts +++ b/packages/plugin-grid/src/relationalMetaKeys.ts @@ -86,20 +86,26 @@ * because `renderCellEditor` hands the widget the whole schema def. * `__tests__/relationalMetaCopySet-7166.test.tsx` renders both halves. * - * ## ⚠️ THE TWO POPULATIONS — why three keys went and three stayed - * - * Six copied keys have no reader on this bag; only three left. The other three - * — `description_field`, `lookup_filters`, `id_field` — are the snake_case - * `legacy-alias` spellings, recorded that way PRECISELY because a host - * `DataSource` outside these two repos may hand-feed them. That is a - * PRODUCER-side argument, and every measurement above is READER-side. Retiring - * them on this evidence would answer a question nobody asked. - * - * Their reader-side verdict is now on the table anyway, so the next pass - * inherits a measurement instead of a silence. ⛔ The question still OPEN — and - * which no evidence gathered in these two repos can close — is the producer - * one: does any host outside them put these spellings on a field def? Closing - * it needs a producer survey, not another sweep of this repo. + * ## ⭐ THE TWO POPULATIONS — settled by objectui#7155, not by a reader sweep + * + * Six copied keys had no reader on this bag; three left in objectui#7166. The + * other three — `description_field`, `lookup_filters`, `id_field` — were held + * back as `legacy-alias`, on a PRODUCER-side argument no reader measurement + * could answer: a host `DataSource` outside these two repos might hand-feed + * them. + * + * That question is now CLOSED, and the answer was not the expected one. The + * host feeding snake_case was THIS REPO: `@object-ui/types` declared the snake + * spellings on `LookupFieldMetadata` / `UserFieldMetadata` and REFUSED the + * camelCase ones, `content/docs/fields/lookup.mdx` taught that dialect as + * normative, and CI compiled those snippets on every run. Two published + * contracts disagreed about one concept, and the read chains here served both. + * + * objectui#7155 converged them on the spec's camelCase in one payment: the + * widget contract was renamed, the docs and all seven in-repo producers moved + * with it, and the snake legs left the chains. So the four spellings are not + * "retired on reader evidence" — the dialect that produced them no longer + * exists. * * ## ⭐ THE DERIVATION IS SCOPED TO THE CELL — objectui#7187 * @@ -149,15 +155,21 @@ * named — are deliberately NOT copied, and the gate proves their absence from * `FieldSchema` rather than taking this docblock's word for it. * - * ## ⚠️ The asymmetry this file does NOT resolve + * ## ⭐ The asymmetry this file used to record — RESOLVED by objectui#7155 + * + * Four keys in the copy set — `display_field`, `description_field`, + * `lookup_filters`, `id_field` — failed that same producer test and were kept + * as `legacy-alias`, because retiring a shipped key is its own adjudication + * (objectui#6711 and objectui#6874 each were one). * - * Four keys already in the copy set — `display_field`, `description_field`, - * `lookup_filters`, `id_field` — fail that same producer test. They are kept: - * retiring a key that has shipped is its own adjudication (that is what - * objectui#6711 and objectui#6874 each were), a host `DataSource` outside these - * two repos may still hand-feed them, and legacy metadata predating the strict - * schema is not measurable from here. Recorded as `legacy-alias` so the - * asymmetry is visible rather than implied. + * That adjudication happened: the maintainer ruled to converge the two + * contracts rather than to sweep the reader. `display_field`, + * `description_field` and `lookup_filters` are gone in favour of the spec + * spellings they shadowed; `idField` stayed under a new verdict, because + * objectstack#3508's machine-name hydration is a picker capability with no + * object-metadata twin and none owed. The `legacy-alias` verdict itself is + * retired — the class is empty and unrepresentable, and the gate below proves + * the four spellings reach no consumer rather than trusting this paragraph. * * ## ⛔ Two keys were in this list and are RETIRED — do not re-add them * @@ -219,8 +231,15 @@ export type RelationalMetaVerdict = | 'spec' /** Not spec-declared, but stamped onto every def by the adapter's choke point. */ | 'adapter-stamped' - /** Not producible under the installed contract; copied only for back-compat. */ - | 'legacy-alias' + /** + * Declared on `@object-ui/types`' WIDGET metadata (`LookupFieldMetadata` / + * `UserFieldMetadata`) and emitted by in-repo producers, with no twin on + * `FieldSchema` — and none needed. objectui#7155 created this category when + * it converged the two dialects: `idField` carries objectstack#3508's + * machine-name hydration, which is a picker capability and not an + * object-metadata one. + */ + | 'widget-contract' /** Read, but no producer can emit it — copying it would reach nothing. */ | 'no-producer' /** Producible and read, but written onto the meta by another block already. */ @@ -270,7 +289,7 @@ const LOOKUP_EDITOR_ONLY: readonly RelationalMetaConsumer[] = Object.freeze(['lo const PRODUCER_LICENSED_VERDICTS: ReadonlySet = new Set([ 'spec', 'adapter-stamped', - 'legacy-alias', + 'widget-contract', ]); export interface RelationalMetaEntry { @@ -285,10 +304,11 @@ export interface RelationalMetaEntry { * Present ONLY on a key copied although NO consumer fed this bag reads it — * the one exit from the cell-reader rule, and it has to state its own reason. * - * ⛔ The gate confines it to `legacy-alias`, which is separately proved - * non-authorable. So a spec-declared key can never take this exit: it is not - * a widening of the rule, it is the producer-side argument the snake_case - * spellings were kept on, written where it can be read. + * ⛔ NO entry takes this exit any more. It existed for the `legacy-alias` + * class — the snake_case spellings kept on a producer-side argument — and + * objectui#7155 retired that class by converging the contracts. The field is + * kept, and the gate asserts it is UNUSED, so re-opening the exit is a + * deliberate act with a stated reason rather than a quiet re-entry. */ readonly copiedWithoutCellReader?: string; readonly note: string; @@ -308,16 +328,14 @@ export const RELATIONAL_META_READ_SET: Readonly