diff --git a/.changeset/retire-owner-widget-alias.md b/.changeset/retire-owner-widget-alias.md new file mode 100644 index 0000000000..48a5a96ce9 --- /dev/null +++ b/.changeset/retire-owner-widget-alias.md @@ -0,0 +1,35 @@ +--- +"@object-ui/fields": patch +"@object-ui/plugin-grid": patch +"@object-ui/app-shell": patch +--- + +fix(fields): retire the `owner` field-type alias with a loud tombstone + +`owner` was a synonym for `user` with zero behavioral delta — both resolved to +the same `UserField` widget — and it is not a member of `@objectstack/spec`'s +closed `FieldType`, so no object schema could ever declare it. It was reachable +only through hand-written SDUI, and the three code faces that read it had +already drifted apart on the word: the form's data-source rule excluded it, +while plugin-grid's bulk-action dialog and app-shell's `paramToField` included +it. + +The retired spelling now fails **loudly**. Deleting the alias on its own would +have been absorbed by two silent tails (`mapFieldTypeToFormType`'s +`|| 'field:text'` and `resolveFormWidgetType`'s `: 'text'`), each handing back a +working plain text input with no check turning red — so anyone who had written +`type: 'owner'`, including an AI author copying it out of a doc, would have +shipped a text box believing they shipped a person picker. Instead: + +- `type: 'owner'` and `widget: 'field:owner'` both resolve to a registered + tombstone widget that renders a visible refusal naming the migration; +- the same prescription is written to the console once per spelling; +- the read/cell path degrades to the text cell deliberately and says so. + +Migration: write the record-owner field as `{ type: 'user', name: 'owner' }` — +the field NAME carries the ownership meaning, the type carries the widget. +`UserField` and `UserCellRenderer` are unchanged; only the synonym is gone. + +Also corrects the `dataSource` TSDoc in `@object-ui/fields`, which listed `grid` +among the widgets the form renderer wires a DataSource to. `GridField` never +read `dataSource` and no data-source table ever contained the key. diff --git a/content/docs/components/complex/filter-builder.mdx b/content/docs/components/complex/filter-builder.mdx index 2a9c2c0655..1abbf95027 100644 --- a/content/docs/components/complex/filter-builder.mdx +++ b/content/docs/components/complex/filter-builder.mdx @@ -24,7 +24,7 @@ interface FilterField { | 'date' | 'datetime' | 'time' | 'boolean' | 'select' | 'status' - | 'lookup' | 'master_detail' | 'user' | 'owner'; // Field type + | 'lookup' | 'master_detail' | 'user'; // Field type options?: Array<{ value: string; label: string }>; // Static options (select-like) // Lookup-like fields without `options` render a remote-search picker that // queries the configured DataSource. The metadata below describes how to diff --git a/content/docs/core/report-schema.mdx b/content/docs/core/report-schema.mdx index 8bc7bf4071..d637802d72 100644 --- a/content/docs/core/report-schema.mdx +++ b/content/docs/core/report-schema.mdx @@ -96,7 +96,7 @@ interface ReportField { | 'select' | 'multi_select' | 'status' | 'lookup' | 'reference' | 'master_detail' | 'email' | 'url' | 'phone' | 'currency' | 'percent' - | 'image' | 'file' | 'user' | 'owner' + | 'image' | 'file' | 'user' | 'richtext' | 'html' | 'markdown' | 'json' | 'tags'; // Used when type is select / multi_select / status. diff --git a/content/docs/fields/user.mdx b/content/docs/fields/user.mdx index f173a0f6c6..fe9cce21ed 100644 --- a/content/docs/fields/user.mdx +++ b/content/docs/fields/user.mdx @@ -13,7 +13,11 @@ The User Field component provides a user selector for assigning users or owners -## Owner Field +## Read-Only Record Owner + +A record-owner field is a plain `user` field whose NAME carries the ownership +meaning — there is no separate owner type. Marking it `readonly` is what makes +it display-only. @@ -21,7 +25,7 @@ The User Field component provides a user selector for assigning users or owners ```plaintext interface UserFieldSchema { - type: 'user' | 'owner'; + type: 'user'; name: string; // Field name/ID label?: string; // Field label value?: User | User[]; // Selected user(s) @@ -45,8 +49,18 @@ interface User { ## User vs Owner -- **User Field**: Selectable user field for assignments, team members, etc. -- **Owner Field**: Typically read-only, automatically set to the record creator +Both are the same field **type**. What differs is the field's name and whether +it is writable: + +- **Assignment field**: a selectable `user` field for assignees, team members, etc. +- **Owner field**: a `user` field named `owner`, typically `readonly` and + defaulted to the record creator. + +There is no `owner` field type. It existed as a synonym until objectui#4814 +retired it (it resolved to the very same widget, and it was never a member of +`@objectstack/spec`'s `FieldType`). Authoring `type: 'owner'` now renders a +visible refusal naming this migration rather than silently falling back to a +text input. Write `{ type: 'user', name: 'owner' }` instead. ## Display Features @@ -68,7 +82,7 @@ import { UserCellRenderer } from '@object-ui/fields'; ## How it works -`user` / `owner` fields are a **lookup specialized to the framework's `sys_user` +`user` fields are a **lookup specialized to the framework's `sys_user` object** — there is no custom user API to wire up. The `UserField` widget delegates to the shared lookup picker with the reference fixed to `sys_user`, reusing the same debounced search, record-picker dialog and id resolution as any @@ -88,9 +102,9 @@ No custom user-management integration is required when a `dataSource` is present Common permission configurations: ```plaintext -// Record owner only +// Record owner only — a `user` field whose NAME carries the ownership meaning { - type: 'owner', + type: 'user', name: 'owner', label: 'Owner', readonly: true, diff --git a/examples/schema-catalog/src/schemas/fields-user/record-owner-read-only.json b/examples/schema-catalog/src/schemas/fields-user/record-owner-read-only.json index a9b9de2ce9..fa265486e9 100644 --- a/examples/schema-catalog/src/schemas/fields-user/record-owner-read-only.json +++ b/examples/schema-catalog/src/schemas/fields-user/record-owner-read-only.json @@ -6,7 +6,7 @@ { "name": "record_owner", "label": "Owner", - "type": "owner", + "type": "user", "readonly": true } ] diff --git a/packages/app-shell/src/utils/paramToField.ts b/packages/app-shell/src/utils/paramToField.ts index 2bc53f768d..aad13fcd4b 100644 --- a/packages/app-shell/src/utils/paramToField.ts +++ b/packages/app-shell/src/utils/paramToField.ts @@ -91,7 +91,10 @@ export function paramToField(param: ActionParamDef): Record { field.widget = 'checkbox'; } - if (LOOKUP_WIDGET_TYPES.has(type) || type === 'user' || type === 'owner') { + // `|| type === 'owner'` stood here until objectui#4814 retired that spelling + // (ruling A′). It moves in lockstep with plugin-grid's `bulkParamToField` + // twin — the two param faces are never split. + if (LOOKUP_WIDGET_TYPES.has(type) || type === 'user') { Object.assign(field, { reference_to: param.referenceTo, display_field: param.displayField, diff --git a/packages/app-shell/src/utils/paramValueShape.ts b/packages/app-shell/src/utils/paramValueShape.ts index 1a85baa59e..7787bdd440 100644 --- a/packages/app-shell/src/utils/paramValueShape.ts +++ b/packages/app-shell/src/utils/paramValueShape.ts @@ -150,7 +150,10 @@ export const PARAM_VALUE_SHAPES: Readonly> = lookup: { base: 'string', cardinality: 'scalar|array', note: 'Referenced record id; multiple → id[]. (No referenceTo → falls back to a text string.)' }, master_detail: { base: 'string', cardinality: 'scalar|array', note: 'Parent record id — renders the single-value LookupField, not a child list.' }, user: { base: 'string', cardinality: 'scalar|array', note: 'sys_user id; multiple → id[].' }, - owner: { base: 'string', cardinality: 'scalar|array', note: 'Owner (sys_user) id; multiple → id[].' }, + // `owner` had an entry here until objectui#4814 retired the spelling. Removed + // on this card's own drift guard's instruction ("no stale contract entries + // pointing at removed widget types"): a shape declared for a type no form can + // render is a contract for a param nobody can author. // Uploads → fileId string(s) after serializeParamValues (#2698/#2710) file: { base: 'string', cardinality: 'scalar|array', note: 'fileId string after serialize; multiple → fileId[]. Widget state holds a { file_id, name, url, … } descriptor pre-serialize.' }, diff --git a/packages/fields/src/__tests__/capability-multiselect-retired.test.ts b/packages/fields/src/__tests__/capability-multiselect-retired.test.ts index 8ebf0f6a64..cd3b74c297 100644 --- a/packages/fields/src/__tests__/capability-multiselect-retired.test.ts +++ b/packages/fields/src/__tests__/capability-multiselect-retired.test.ts @@ -65,7 +65,11 @@ const RETAINED_FIELD_KEYS = [ 'currency', 'percent', 'password', 'markdown', 'html', 'lookup', 'master_detail', 'file', 'image', 'location', 'formula', 'summary', 'auto_number', - 'user', 'owner', + // `owner` sat beside `user` here until objectui#4814 retired that spelling + // too. It is NOT simply dropped from the floor: `owner-retired.test.tsx` + // asserts `field:owner` now resolves to the tombstone widget, so the key's + // disposition is still pinned — by the card that owns it. + 'user', 'object', 'vector', 'grid', 'color', 'slider', 'rating', 'code', 'avatar', 'address', 'geolocation', 'signature', 'qrcode', @@ -90,7 +94,7 @@ describe('capability-multiselect widget retirement (objectui#3308)', () => { `field:${key} must survive the retirement`, ).toBeTruthy(); } - expect(RETAINED_FIELD_KEYS).toHaveLength(38); + expect(RETAINED_FIELD_KEYS).toHaveLength(37); }); it('has no second registration path left to shadow the live one (objectui#3910)', async () => { diff --git a/packages/fields/src/__tests__/owner-retired.test.tsx b/packages/fields/src/__tests__/owner-retired.test.tsx new file mode 100644 index 0000000000..e8d5da9b7d --- /dev/null +++ b/packages/fields/src/__tests__/owner-retired.test.tsx @@ -0,0 +1,181 @@ +/** + * 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. + */ + +/** + * Retirement pin — the field type `owner` and the widget key `field:owner` + * (objectui#4814, ruling A′, ADR-0049 enforce-or-remove). + * + * What makes this retirement different from `capability-multiselect`'s (whose + * pin lives next door) is WHERE the retired name used to be reachable from. + * `capability-multiselect` was a `widget:` hint that had never been registered + * on the live path, so its retirement is proved by absence: the registry does + * not answer, and nothing else could have. + * + * `owner` was a real, live field TYPE. Deleting it alone would have been + * answered by two silent tails — `mapFieldTypeToFormType`'s `|| 'field:text'` + * and `resolveFormWidgetType`'s `: 'text'` — each of which hands back a working + * plain text input. No gate in this repo turns red on that: `FORM_WIDGET_TYPES` + * in `field-type-coverage.test.ts` never listed `owner` (or `user`), and the + * catalog's hosted test only asserts that a form and a `[data-field]` exist, + * which a TextField satisfies. So an author — or an AI author copying + * `type: 'owner'` out of a doc — would have shipped a text box believing they + * had shipped a person picker, with every check green. + * + * Therefore these assertions are about LOUDNESS, not absence, and they are + * written against the refusal's SHAPE rather than against "it is not a + * UserField": a test that only asserted the latter would pass just as happily + * against the silent text-box regression this card exists to prevent. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; + +import { + registerAllFields, + FORM_FIELD_TYPES, + RetiredFieldTombstone, + RETIRED_FIELD_TYPES, + resetRetiredFieldTypeReports, + mapFieldTypeToFormType, + resolveFormWidgetType, + getCellRenderer, + TextCellRenderer, +} from '../index'; + +const RETIRED = 'owner'; +const SURVIVOR = 'user'; + +beforeEach(() => { + resetRetiredFieldTypeReports(); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('`owner` field-type retirement (objectui#4814)', () => { + describe('the form path refuses loudly instead of degrading to a text box', () => { + it('does NOT resolve the retired type to the field:text fallback', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + // The whole point of the tombstone: `field:text` here is the regression. + expect(mapFieldTypeToFormType(RETIRED)).not.toBe('field:text'); + expect(mapFieldTypeToFormType(RETIRED)).toBe(`field:${RETIRED}`); + }); + + it('does NOT resolve the retired widget key to the `text` widget', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(resolveFormWidgetType(RETIRED)).not.toBe('text'); + expect(resolveFormWidgetType(RETIRED)).toBe(RETIRED); + }); + + it('logs a prescription naming the migration, not a bare "unknown type"', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + mapFieldTypeToFormType(RETIRED); + + const said = error.mock.calls.map((c) => String(c[0])).join('\n'); + // The message must be actionable on its own — this is the text an agent + // or a developer will follow, so its content is the contract. + expect(said).toContain('`owner`'); + expect(said).toContain('RETIRED'); + expect(said).toContain("{ type: 'user', name: 'owner' }"); + expect(said).toContain('objectui#4814'); + }); + + it('reports once per spelling, so a rendered list cannot bury the message', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + for (let i = 0; i < 50; i++) mapFieldTypeToFormType(RETIRED); + + const retiredMessages = error.mock.calls + .map((c) => String(c[0])) + .filter((m) => m.includes('objectui#4814')); + expect(retiredMessages).toHaveLength(1); + }); + + it('is not a renderable field type any more', () => { + expect(FORM_FIELD_TYPES).not.toContain(RETIRED); + // …and the survivor is untouched, so this is a subtraction of exactly one. + expect(FORM_FIELD_TYPES).toContain(SURVIVOR); + }); + }); + + describe('both authored spellings land on the visible refusal', () => { + it('registers the tombstone under `field:owner`, so `widget: "field:owner"` reaches it', () => { + registerAllFields(); + // This is the exact lookup `form.tsx`'s `renderFieldComponent` performs + // for BOTH `widget: 'field:owner'` and a hand-written `type: 'owner'`. + expect(ComponentRegistry.get(`field:${RETIRED}`)).toBe(RetiredFieldTombstone); + }); + + it('does not claim the bare `owner` global name', () => { + registerAllFields(); + expect(ComponentRegistry.get(RETIRED)).toBeUndefined(); + }); + + it('renders an alert carrying the prescription, not an input', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + render(); + + const alert = screen.getByTestId('field-retired-tombstone'); + expect(alert.getAttribute('role')).toBe('alert'); + expect(alert.textContent).toContain("{ type: 'user', name: 'owner' }"); + // A refusal, not a control the author could mistake for a working field. + expect(document.body.querySelector('input')).toBeNull(); + }); + + it('names the offending spelling on the element, for a host that inspects it', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + render(); + expect( + screen.getByTestId('field-retired-tombstone').getAttribute('data-retired-field-type'), + ).toBe(RETIRED); + }); + }); + + describe('the read/cell path', () => { + it('degrades to the text cell but says so', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const renderer = getCellRenderer(RETIRED); + + expect(renderer).toBe(TextCellRenderer); + const said = error.mock.calls.map((c) => String(c[0])).join('\n'); + expect(said).toContain('objectui#4814'); + }); + }); + + describe('the `user` path is untouched', () => { + it('still maps, resolves and renders as the person picker', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + registerAllFields(); + + expect(mapFieldTypeToFormType(SURVIVOR)).toBe(`field:${SURVIVOR}`); + expect(resolveFormWidgetType(SURVIVOR)).toBe(SURVIVOR); + expect(ComponentRegistry.get(`field:${SURVIVOR}`)).toBeTruthy(); + expect(ComponentRegistry.get(`field:${SURVIVOR}`)).not.toBe(RetiredFieldTombstone); + expect(getCellRenderer(SURVIVOR)).not.toBe(TextCellRenderer); + + // Nothing about a live type may reach the retirement machinery. + expect(error.mock.calls.map((c) => String(c[0])).join('\n')).not.toContain('objectui#4814'); + }); + + it('leaves a genuinely unknown type on the quiet text fallback', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + // Retirement is a NAMED disposition, not a new blanket noise source: an + // unknown type keeps the pre-existing silent fallback. + expect(mapFieldTypeToFormType('something-unknown')).toBe('field:text'); + expect(error).not.toHaveBeenCalled(); + }); + }); + + it('carries exactly the retired vocabulary it claims to', () => { + // Guards the table itself: growing it is a decision, never a side effect. + expect(Object.keys(RETIRED_FIELD_TYPES)).toEqual([RETIRED]); + }); +}); diff --git a/packages/fields/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx b/packages/fields/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx index 3ca8e8b960..538a2d9596 100644 --- a/packages/fields/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +++ b/packages/fields/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx @@ -134,7 +134,9 @@ const WIDGETS: Record> = { summary: SummaryField, auto_number: AutoNumberField, user: UserField, - owner: UserField, + // `owner: UserField` sat here until objectui#4814 retired the spelling. The + // tombstone that replaced it is not a field widget and renders no control, so + // it has no `aria-invalid` surface to scan. object: ObjectField, vector: VectorField, grid: GridField, diff --git a/packages/fields/src/__tests__/widget-dom-leak-e2e.test.tsx b/packages/fields/src/__tests__/widget-dom-leak-e2e.test.tsx index d9de0f4b17..d6a0393235 100644 --- a/packages/fields/src/__tests__/widget-dom-leak-e2e.test.tsx +++ b/packages/fields/src/__tests__/widget-dom-leak-e2e.test.tsx @@ -186,7 +186,9 @@ const WIDGETS: Record> = { summary: SummaryField, auto_number: AutoNumberField, user: UserField, - owner: UserField, + // `owner: UserField` sat here until objectui#4814 retired the spelling. The + // tombstone that replaced it spreads no author props onto the DOM, so it has + // no leak surface to scan. object: ObjectField, vector: VectorField, grid: GridField, diff --git a/packages/fields/src/field-type-alias.multiple.test.ts b/packages/fields/src/field-type-alias.multiple.test.ts index cdddc55dcb..c37710b31a 100644 --- a/packages/fields/src/field-type-alias.multiple.test.ts +++ b/packages/fields/src/field-type-alias.multiple.test.ts @@ -67,7 +67,11 @@ describe('select carries its arity in the widget id (objectui#3986)', () => { * their widget id, and with it their `labelling` declaration, is correct for * either arity. Moving them would point at widgets that do not exist. */ - const UNMOVED_MULTI_CAPABLE = ['lookup', 'master_detail', 'user', 'owner', 'file', 'image', 'radio'] as const; + // `owner` was listed here beside `user` until objectui#4814 retired the + // spelling. It is dropped rather than kept-and-passing: the assertion would + // still be green (both arities reach the tombstone), but it would be pinning + // the arity behaviour of a REFUSAL, which says nothing about this override. + const UNMOVED_MULTI_CAPABLE = ['lookup', 'master_detail', 'user', 'file', 'image', 'radio'] as const; it.each(UNMOVED_MULTI_CAPABLE)('%s resolves identically with and without multiple', (type) => { expect(mapFieldTypeToFormType(type, { multiple: true })).toBe(mapFieldTypeToFormType(type)); diff --git a/packages/fields/src/field-type-alias.ts b/packages/fields/src/field-type-alias.ts index 2387660637..0b6171a677 100644 --- a/packages/fields/src/field-type-alias.ts +++ b/packages/fields/src/field-type-alias.ts @@ -47,6 +47,72 @@ const MULTI_VALUE_FORM_TYPES: Record = { select: 'field:multiselect', }; +/** + * TOMBSTONE table — field-type spellings this renderer has RETIRED, mapped to + * the prescription an author must follow instead (ADR-0049 enforce-or-remove). + * + * A retired spelling is not merely absent: absence here means + * {@link mapFieldTypeToFormType}'s `|| 'field:text'` tail would hand back a + * working plain text input, which is the failure mode this table exists to + * prevent. An author who writes a retired name — or an AI author who copies one + * out of a stale doc — must be TOLD, not quietly given a text box that looks + * like it worked. So each entry resolves to {@link RETIRED_WIDGET_KEY_PREFIX} + * plus the retired name: a registered tombstone widget that renders a visible + * refusal naming the migration (`packages/fields/src/index.tsx`), while + * {@link reportRetiredFieldType} writes the same prescription to the console. + * + * `owner` (objectui#4814, ruling A′): a synonym for `user` with zero behavioral + * delta — both resolved to the SAME `UserField` widget — and absent from the + * spec's closed 48-member `FieldType`, so no object schema could ever declare + * it; it was reachable only through hand-written SDUI. Three code faces had + * already drifted apart on this one word (the form's data-source rule excluded + * it while plugin-grid's bulk dialog included it), which is the standing + * evidence that a second spelling for one concept is a drift channel, not a + * convenience. The idiom survives verbatim as `{ type: 'user', name: 'owner' }`. + */ +export const RETIRED_FIELD_TYPES: Readonly> = Object.freeze({ + owner: + "[object-ui] Field type `owner` was RETIRED (objectui#4814). It was a synonym " + + "for `user` with no behavioral difference, and it is not a member of " + + "`@objectstack/spec`'s FieldType. Write the record-owner field as " + + "`{ type: 'user', name: 'owner' }` — the field NAME carries the ownership " + + "meaning, the type carries the widget. The `widget: 'field:owner'` spelling " + + "is retired with it.", +}); + +/** Namespace prefix the retired spellings resolve into. */ +const RETIRED_WIDGET_KEY_PREFIX = 'field:'; + +/** + * Spellings already reported this session, so a retired type inside a rendered + * list logs its prescription ONCE instead of once per row. The message is a + * fix instruction for an author, not a per-render event — a 1000-row grid + * repeating it 1000 times buries the very thing it is trying to surface. + */ +const reportedRetiredTypes = new Set(); + +/** + * Report a retired field-type spelling loudly, once per spelling. + * + * @param fieldType - The spelling the author wrote. + * @returns `true` when `fieldType` is retired (whether or not this call was the + * one that logged), so callers can branch on it without a second table lookup. + */ +export function reportRetiredFieldType(fieldType: string): boolean { + const prescription = RETIRED_FIELD_TYPES[fieldType]; + if (!prescription) return false; + if (!reportedRetiredTypes.has(fieldType)) { + reportedRetiredTypes.add(fieldType); + console.error(prescription); + } + return true; +} + +/** Test seam — forget which spellings have been reported. */ +export function resetRetiredFieldTypeReports(): void { + reportedRetiredTypes.clear(); +} + /** * Map field type to form component type * @@ -98,11 +164,14 @@ export function mapFieldTypeToFormType( lookup: 'field:lookup', master_detail: 'field:master_detail', tree: 'field:lookup', // hierarchical reference — pick the parent via a lookup - // `user` is a lookup specialized to sys_user; `owner` mirrors it (record - // ownership). Both render via the UserField person-picker (delegates to the - // lookup picker). Without these they would fall through to `field:text`. + // `user` is a lookup specialized to sys_user, rendered by the UserField + // person-picker (which delegates to the lookup picker). Without it the type + // would fall through to `field:text`. + // + // `owner` was its synonym until objectui#4814 retired it — see + // {@link RETIRED_FIELD_TYPES}. It is deliberately NOT re-added here: a + // retired spelling must not resolve through the live table. user: 'field:user', - owner: 'field:owner', // Contact fields email: 'field:email', @@ -152,5 +221,15 @@ export function mapFieldTypeToFormType( return MULTI_VALUE_FORM_TYPES[fieldType]; } - return typeMap[fieldType] || 'field:text'; + const live = typeMap[fieldType]; + if (live) return live; + + // A RETIRED spelling is answered before the `field:text` tail, and never by + // it: falling through would render a working text input, which is exactly the + // silent degradation the tombstone exists to prevent (objectui#4814). + if (reportRetiredFieldType(fieldType)) { + return `${RETIRED_WIDGET_KEY_PREFIX}${fieldType}`; + } + + return 'field:text'; } diff --git a/packages/fields/src/field-type-coverage.test.ts b/packages/fields/src/field-type-coverage.test.ts index 5c6fcc5070..af603d7cfa 100644 --- a/packages/fields/src/field-type-coverage.test.ts +++ b/packages/fields/src/field-type-coverage.test.ts @@ -45,7 +45,12 @@ const CELL_RENDERER_TYPES = [ 'file', 'video', 'audio', 'image', 'avatar', 'signature', 'markdown', 'html', 'richtext', 'location', 'geolocation', 'address', 'color', 'json', - 'formula', 'summary', 'user', 'owner', + // `owner` was listed here (asserting a dedicated cell renderer) until + // objectui#4814 retired the spelling. Its retirement is pinned by + // `__tests__/owner-retired.test.tsx`, which asserts the OPPOSITE — the text + // cell plus a console prescription — so the fact is stated in exactly one + // place rather than half-stated in two. + 'formula', 'summary', 'user', ]; describe('field-type renderer coverage (regression guard)', () => { diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index c2326890cd..c0c4ece541 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -2111,7 +2111,15 @@ export function getCellRenderer(fieldType: string): React.FC if (fieldRegistry.has(fieldType)) { return fieldRegistry.get(fieldType)!; } - + + // 1b. A RETIRED spelling reaching the read path says a stored column is still + // typed with a name this renderer no longer honours. There is no visible + // alert a table CELL can carry without wrecking the row, so the console + // prescription is the loud half here (once per spelling — + // `reportRetiredFieldType`), and the cell degrades to text deliberately + // rather than by omission (objectui#4814). + reportRetiredFieldType(fieldType); + // 2. Fallback to standard mappings if not overridden const standardMap: Record> = { text: TextCellRenderer, @@ -2154,7 +2162,6 @@ export function getCellRenderer(fieldType: string): React.FC summary: FormulaCellRenderer, auto_number: TextCellRenderer, user: UserCellRenderer, - owner: UserCellRenderer, password: () => ••••••, secret: () => ••••••, location: LocationCellRenderer, @@ -2187,7 +2194,9 @@ registerFieldRenderer('master_detail', LookupCellRenderer); registerFieldRenderer('select', SelectCellRenderer); registerFieldRenderer('status', SelectCellRenderer); registerFieldRenderer('user', UserCellRenderer); -registerFieldRenderer('owner', UserCellRenderer); +// `owner` was registered here to the same UserCellRenderer until objectui#4814 +// retired the spelling — see the TOMBSTONE below. `getCellRenderer('owner')` +// now reports the prescription and falls to the text cell. // Register getCellRenderer in the bridge so RecordPickerDialog can access it // via LookupField without circular imports. @@ -2200,6 +2209,8 @@ setCellRendererResolver(getCellRenderer); // FieldEditWidget can resolve spec aliases without importing this barrel. export { mapFieldTypeToFormType } from './field-type-alias'; import { mapFieldTypeToFormType } from './field-type-alias'; +export { RETIRED_FIELD_TYPES, reportRetiredFieldType, resetRetiredFieldTypeReports } from './field-type-alias'; +import { RETIRED_FIELD_TYPES, reportRetiredFieldType } from './field-type-alias'; /** * Formats file size in bytes to human-readable string @@ -2430,10 +2441,12 @@ const fieldWidgetMap: Record Promise<{ default: React.ComponentTyp 'summary': () => import('./widgets/SummaryField').then(m => ({ default: m.SummaryField })), 'auto_number': () => import('./widgets/AutoNumberField').then(m => ({ default: m.AutoNumberField })), - // User fields + // User fields. `owner` pointed at this same UserField until objectui#4814 + // retired it (see the TOMBSTONE near `registerAllFields`) — do not re-add it + // here: membership in this map is what makes a name a renderable field type + // (`FORM_FIELD_TYPES` is its key set). 'user': () => import('./widgets/UserField').then(m => ({ default: m.UserField })), - 'owner': () => import('./widgets/UserField').then(m => ({ default: m.UserField })), - + // Complex data types 'object': () => import('./widgets/ObjectField').then(m => ({ default: m.ObjectField })), 'vector': () => import('./widgets/VectorField').then(m => ({ default: m.VectorField })), @@ -2482,6 +2495,11 @@ export const FORM_FIELD_TYPES: readonly string[] = Object.freeze(Object.keys(fie */ export function resolveFormWidgetType(fieldType: string): string { if (fieldWidgetMap[fieldType]) return fieldType; + // A retired spelling resolves to ITSELF, not to `text`: the registry holds a + // tombstone widget under that key which refuses visibly, so every host built + // on this seam (the app-shell `ActionParamDialog`, the bulk dialog) reports + // the retirement instead of silently rendering an input (objectui#4814). + if (RETIRED_FIELD_TYPES[fieldType]) return fieldType; const mapped = mapFieldTypeToFormType(fieldType).replace(/^field:/, ''); return fieldWidgetMap[mapped] ? mapped : 'text'; } @@ -2512,6 +2530,10 @@ const lazyFieldWidgets = new Map>(); */ export function getLazyFieldWidget(fieldType: string): React.ComponentType { const key = resolveFormWidgetType(fieldType); + // A retired key has no loader in `fieldWidgetMap` by construction, so it is + // answered with the tombstone before the lazy path (which would otherwise + // call `React.lazy(undefined)`). + if (RETIRED_FIELD_TYPES[key]) return RetiredFieldTombstone; let Widget = lazyFieldWidgets.get(key); if (!Widget) { Widget = React.lazy(fieldWidgetMap[key]); @@ -2644,10 +2666,53 @@ export function registerField(fieldType: string): void { * // Register all fields at once * registerAllFields(); */ +/** + * The widget a RETIRED field-type spelling renders (objectui#4814). + * + * Shape borrowed from the form renderer's spec-vocabulary boundary (#3090), + * which is this repo's settled answer to "an authored entry this renderer + * cannot honour": an inline alert that NAMES the offending entry, plus a + * `console.error` whose text doubles as the fix instruction. Nothing is thrown + * — one retired field must not take down the rest of a record form — but + * nothing is silently substituted either, which is the whole point: the author + * sees a refusal where they expected an input, not a text box that looks like + * it worked. + */ +export const RetiredFieldTombstone: React.FC> = (props) => { + const spelling: string = + props?.field?.type ?? props?.schema?.type ?? props?.type ?? 'unknown'; + const prescription = + RETIRED_FIELD_TYPES[spelling] ?? + `[object-ui] Field type \`${spelling}\` was retired.`; + React.useEffect(() => { + reportRetiredFieldType(spelling); + }, [spelling]); + return ( +
+ {prescription} +
+ ); +}; + export function registerAllFields(): void { Object.keys(fieldWidgetMap).forEach(fieldType => { registerField(fieldType); }); + // Retired spellings are registered LAST and only under the `field:` namespace + // (`skipFallback` — a tombstone must not claim the bare global name). This is + // what makes `widget: 'field:owner'` and a hand-written `type: 'owner'` land + // on a visible refusal instead of falling through to the form's text input. + Object.keys(RETIRED_FIELD_TYPES).forEach(fieldType => { + ComponentRegistry.register(fieldType, RetiredFieldTombstone, { + namespace: 'field', + skipFallback: true, + }); + }); } // TOMBSTONE (objectui#3910, ruling B of objectui#3798) — `registerFields()` lived @@ -2681,6 +2746,34 @@ export function registerAllFields(): void { // widget NAME stays retired — do not add it to `fieldWidgetMap`. // `CapabilityMultiSelectField` itself lives on as a plain component, imported and // rendered directly by Studio's `PermissionMatrixEditor` (ADR-0056 P2's design). +// +// TOMBSTONE (objectui#4814, ruling A′, ADR-0049 enforce-or-remove) — the field +// type `owner` and its widget key `field:owner`. Both pointed at `UserField`, +// the SAME widget `user` resolves to, so the word carried zero behavioral delta; +// and `owner` is absent from `@objectstack/spec`'s closed 48-member `FieldType`, +// so no object schema could declare it — it was reachable only through +// hand-written SDUI. Three code faces had already drifted apart on this one +// word: the form's data-source rule excluded it, plugin-grid's bulk dialog +// included it, and app-shell's `paramToField` included it — which is the +// standing evidence that a second spelling for one concept is a drift channel, +// not a convenience. +// +// The retirement is LOUD, not silent, and that distinction is the ruling's +// point. `mapFieldTypeToFormType`'s `|| 'field:text'` tail and +// `resolveFormWidgetType`'s `: 'text'` tail would each have handed a retired +// `owner` field a working plain text input, with no gate anywhere turning red — +// an AI author copying `type: 'owner'` out of a stale doc would have shipped a +// text box believing it shipped a person picker. So `owner` resolves to +// `RetiredFieldTombstone` (a visible refusal) and `reportRetiredFieldType` +// writes the migration to the console. This is also the designed answer to the +// one surface this retirement could not measure: the `cloud` repo was never +// scanned (no credentials in the measuring session), so any consumer living +// there fails loudly and nameably instead of degrading in silence. +// +// Do not re-add `owner` to `fieldWidgetMap` or to `field-type-alias`'s live +// `typeMap`. The surviving idiom is `{ type: 'user', name: 'owner' }` — the +// field NAME carries ownership meaning, the type carries the widget. +// `UserField` and `UserCellRenderer` are untouched; only the synonym is gone. export * from './widgets/types'; // File field value shapes (ObjectStack ADR-0104 D3 wave 2) — the single diff --git a/packages/fields/src/widgets/types.ts b/packages/fields/src/widgets/types.ts index 5c35d7d885..79e2b48aae 100644 --- a/packages/fields/src/widgets/types.ts +++ b/packages/fields/src/widgets/types.ts @@ -164,9 +164,14 @@ export type FieldWidgetComponentProps = { /** * DataSource for widgets that query records (lookup / user / object-ref / - * recipient-picker / grid). Injected by the form renderer for the field - * types that need it, and passed directly by inline-edit hosts. Option - * widgets destructure it purely to keep it off their DOM spread. + * recipient-picker). Injected by the form renderer for the field types that + * need it, and passed directly by inline-edit hosts. Option widgets + * destructure it purely to keep it off their DOM spread. + * + * `grid` was listed here with zero consumers (objectui#4814): `GridField.tsx` + * never reads `dataSource`, and no data-source table has ever contained the + * key, so the claim described a wiring that did not exist. `owner` is absent + * for a different reason — the spelling itself is retired (same card). * * Left structural (`unknown`): `@object-ui/fields` must not depend on a * concrete adapter — every consumer narrows it itself. diff --git a/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx b/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx index d0de04f343..8c8f303c9b 100644 --- a/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx +++ b/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx @@ -74,8 +74,13 @@ import { isComputedFieldType, isInlineExcludedDetailFieldType } from '../fieldEn * reaches a widget through the alias table — `toggle`, `progress`, `json`, * `composite`, `autonumber`, `secret`, `video` — which is precisely the set * that fell through the switch in #2942 and again here. The spec enum misses - * the form-only keys (`owner`, `object`, `grid`, `geolocation`, the - * widget-hint pickers). + * the form-only keys (`object`, `grid`, `geolocation`, the widget-hint + * pickers). + * + * `owner` was named here as a form-only key until objectui#4814 retired the + * spelling. It is now in NEITHER set — gone from `FORM_FIELD_TYPES`, and never + * a member of the spec enum, which is the card's own premise — so it leaves + * this universe entirely rather than moving between buckets. */ const specTypes: string[] = Array.isArray((FieldType as { options?: readonly string[] }).options) ? [...(FieldType as { options: readonly string[] }).options] @@ -228,10 +233,20 @@ describe('inline-edit type coverage — every type has exactly one decision (#42 'recipient-picker', 'record', 'repeater', 'richtext', 'secret', 'summary', 'vector', ], + // `owner` stood between `number` and `percent` until objectui#4814 + // retired the spelling. It is NOT re-bucketed here: it left `ALL_TYPES` + // altogether (see the universe note above), so there is no decision left + // to document. The retirement itself is pinned in one place — + // `packages/fields/src/__tests__/owner-retired.test.tsx`. + // + // Note this snapshot is derived from `ALL_TYPES`, not from + // `INLINE_ROUTED_FIELD_TYPES`, which still lists `'owner'` + // (`InlineFieldInput.tsx`). That member is now unreachable through this + // guard and is tracked in #4914, not forced away here. routed: [ 'address', 'audio', 'avatar', 'boolean', 'currency', 'date', 'datetime', 'file', 'geolocation', 'image', 'location', 'lookup', 'master_detail', - 'multiselect', 'number', 'owner', 'percent', 'select', 'signature', + 'multiselect', 'number', 'percent', 'select', 'signature', 'tree', 'user', 'video', ], delegated: ['checkboxes', 'code', 'color', 'json', 'progress', 'qrcode', 'radio', 'rating', 'slider', 'tags', 'time', 'toggle'], diff --git a/packages/plugin-grid/src/components/bulkParamToField.ts b/packages/plugin-grid/src/components/bulkParamToField.ts index 0bf9c701ac..9f57a3b319 100644 --- a/packages/plugin-grid/src/components/bulkParamToField.ts +++ b/packages/plugin-grid/src/components/bulkParamToField.ts @@ -39,8 +39,16 @@ const BULK_PARAM_TYPE_ALIASES: Record = { /** Widget keys that render the record-picker family and need a reference target. */ const LOOKUP_WIDGET_TYPES = new Set(['lookup', 'master_detail']); -/** Widget keys that render the person-picker family (target defaults to sys_user). */ -const USER_WIDGET_TYPES = new Set(['user', 'owner']); +/** + * Widget keys that render the person-picker family (target defaults to sys_user). + * + * `owner` was a member until objectui#4814 retired the spelling (ruling A′): it + * was a synonym for `user` resolving to the same widget, and this set was one of + * the three code faces that had drifted apart on the word. It moves in lockstep + * with app-shell's `paramToField` — the twin this file mirrors — so the two + * param surfaces can never disagree about it again. + */ +const USER_WIDGET_TYPES = new Set(['user']); /** Widget keys whose widget must be handed the grid's DataSource explicitly. */ const DATA_SOURCE_WIDGET_TYPES = new Set([...LOOKUP_WIDGET_TYPES, ...USER_WIDGET_TYPES]);