diff --git a/.changeset/owner-retired-published-contract-twins.md b/.changeset/owner-retired-published-contract-twins.md new file mode 100644 index 0000000000..7b1436c769 --- /dev/null +++ b/.changeset/owner-retired-published-contract-twins.md @@ -0,0 +1,45 @@ +--- +'@object-ui/types': patch +'@object-ui/plugin-detail': patch +--- + +The retired `owner` field-type spelling stops being blessed by the published contract, and inline edit refuses it the way the record form already does. + +objectui#4814 retired `owner` as a field type (ruling A′): it was a synonym for +`user` with zero behavioral delta — both resolved to the same person-picker +widget — and it was never a member of `@objectstack/spec`'s closed `FieldType`, +so no object schema could ever declare it. `@object-ui/fields` now answers the +spelling with a visible tombstone refusal plus a console prescription. That PR +shrank the three public DOC unions; their CODE twins were left behind, so this +package spent the interval telling an author "legal" for a word the renderer +refuses. + +**`@object-ui/types` — the three published twins shrink (objectui#4914 items 1-3).** +`ReportFieldSchema.type` (`zod/reports.zod.ts`) is a RUNTIME validator, so the +contradiction was executable, not merely advisory: a report document authored +with `type: 'owner'` validated green and then rendered a refusal. It now fails +validation, with the issue on the `type` path. Its TS twin `ReportField['type']` +and `UserFieldMetadata['type']` drop the member in the same batch, so published +`.d.ts` autocomplete stops offering it. This is an accept-set SHRINK on a +published validator and a narrowing of two published unions — patch-level +because the spelling it removes has had no working renderer since #4814, but +callers still passing `type: 'owner'` will now see a type error and a failed +parse. The record-owner idiom survives verbatim as +`{ type: 'user', name: 'owner' }`: the field NAME carries the ownership meaning, +the type carries the widget. + +**`@object-ui/plugin-detail` — inline edit joins the tombstone (objectui#4914 item 5).** +`InlineFieldInput` routes by a STORED field's actual type, so a record whose +field is still typed `owner` was getting a working person picker inline while +the record form showed the refusal — two edit surfaces disagreeing about one +field, which is worse than either uniform outcome. A retired spelling now +renders the same `RetiredFieldTombstone` the form does, reported once per +spelling rather than once per row. The table is read live from +`@object-ui/fields`, so a future retirement is covered the day it lands. + +Measured while implementing, and the reason the refusal is the load-bearing +half: simply deleting `owner` from the inline routing table would have changed +nothing an author could see. `hasFieldEditWidget('owner')` is still true — the +fields package maps `owner: UserField` in `EDIT_WIDGETS` — so the type would +have reached the same picker down the delegation road instead of the routing +road. That residual face is outside this change's scope and is filed separately. diff --git a/packages/plugin-detail/src/InlineFieldInput.tsx b/packages/plugin-detail/src/InlineFieldInput.tsx index 2b54c4e31d..9937ced940 100644 --- a/packages/plugin-detail/src/InlineFieldInput.tsx +++ b/packages/plugin-detail/src/InlineFieldInput.tsx @@ -27,6 +27,8 @@ import { mapFieldTypeToFormType, FORM_FIELD_TYPES, coerceToSafeValue, + RETIRED_FIELD_TYPES, + RetiredFieldTombstone, } from '@object-ui/fields'; import { PermissionFacetLink } from './renderers/PermissionFacetLink'; import { TEXTUAL_REF_FALLBACK_TYPES } from './fieldEnrichment'; @@ -103,8 +105,28 @@ export const INLINE_ROUTED_FIELD_TYPES = new Set([ 'date', 'datetime', // Structured composites (#4216 / #4222) 'address', 'location', 'geolocation', - // Relational pickers - 'lookup', 'master_detail', 'tree', 'user', 'owner', + // Relational pickers. + // + // `owner` sat beside `user` until objectui#4914 removed it (the fast-follow + // to objectui#4814's ruling A′, which retired the spelling). Unlike the dead + // predicate branches that retirement left behind, this set is consulted at + // runtime against whatever type a STORED field actually carries — so while + // `owner` was a member, a field still typed `owner` got a working person + // picker in inline edit while the record FORM rendered the tombstone + // refusal: two edit surfaces disagreeing about one field, which is worse + // than either uniform outcome. + // + // Deleting the member ALONE would have changed nothing an author could see — + // measured, not assumed. The delegation tail at the bottom of this component + // asks `hasFieldEditWidget`, and the fields package still answers `true` for + // `owner` (`EDIT_WIDGETS` maps it to `UserField`, + // `packages/fields/src/FieldEditWidget.tsx` — a residual face #4914 does not + // enumerate, filed separately). So the type would have reached the SAME + // person picker by the delegation road instead of the routing road. That is + // why this removal moves in lockstep with the retired-spelling refusal at + // the top of `InlineFieldInput`, which runs before both roads: the refusal + // is what actually changes the behavior, and the two are never split. + 'lookup', 'master_detail', 'tree', 'user', // Binary / attachment — the detail page's exemption from the shared // exclusion (#4228): a row can host an upload widget, a grid cell cannot. 'image', 'avatar', 'signature', 'file', 'video', 'audio', @@ -226,6 +248,31 @@ export const InlineFieldInput: React.FC = ({ if (editWidget === 'permission-facet-link') { return ; } + // A RETIRED field-type spelling meets the SAME loud refusal here that the + // record form gives it (objectui#4914, fast-follow to #4814's ruling A′). + // + // This is the branch that makes the two edit surfaces agree. The form path + // resolves `type: 'owner'` through `ComponentRegistry.get('field:owner')`, + // which the fields package answers with `RetiredFieldTombstone` — a visible + // refusal naming the migration, plus a console prescription deduped once per + // spelling. Inline edit reaches no registry at all — it routes by type and + // then delegates — so without this branch it answers a retired spelling on + // its own, and BOTH of its answers are a working person picker: the routing + // road while `owner` was in `INLINE_ROUTED_FIELD_TYPES`, and the delegation + // road afterwards, because `hasFieldEditWidget('owner')` is still true + // (`EDIT_WIDGETS` maps it to `UserField`). Removing the set member is + // therefore not by itself a behavior change; this branch is. + // `RetiredFieldTombstone` reports through the same once-per-spelling seam as + // the form, so an inline-edited retired field logs the prescription once, + // not once per row entered. + // + // Keyed on the TYPE and placed after the widget-hint branch, mirroring the + // form's own precedence (an explicit `widget` hint resolves before the + // mapped type). The table is read live from `@object-ui/fields`, never + // copied — a future retirement is covered here the day it lands. + if (typeof editType === 'string' && RETIRED_FIELD_TYPES[editType]) { + return ; + } // Picklist → real Select widget so users see localized option labels and // can't free-type invalid values. A `multiple` picklist (spec canon: `select` // + `multiple: true`; `multiselect` is the widget-level alias) selects @@ -287,7 +334,13 @@ export const InlineFieldInput: React.FC = ({ // to re-fetch the referenced record just to recover the name the record // page's `populate=` already delivered. `extractLookupId` stays exported for // write-side callers that need the bare id. - const isUserRef = editType === 'user' || editType === 'owner'; + // + // `|| editType === 'owner'` stood here until objectui#4914 removed it — the + // second half of the same lockstep as the `INLINE_ROUTED_FIELD_TYPES` + // membership above (both faces were measured together on objectui#4814's + // patch round). A retired spelling never reaches this line now: the refusal + // at the top of the component answers it first. + const isUserRef = editType === 'user'; const isLookupRef = editType === 'lookup' || editType === 'master_detail' || diff --git a/packages/plugin-detail/src/__tests__/InlineFieldInput.retiredFieldType.test.tsx b/packages/plugin-detail/src/__tests__/InlineFieldInput.retiredFieldType.test.tsx new file mode 100644 index 0000000000..07cebea666 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/InlineFieldInput.retiredFieldType.test.tsx @@ -0,0 +1,189 @@ +/** + * 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 — a RETIRED field-type spelling in inline edit (objectui#4914, + * fast-follow to objectui#4814's ruling A′). + * + * #4814 retired the `owner` field type with a loud tombstone: the record FORM + * answers `type: 'owner'` with a visible refusal naming the migration, plus a + * console prescription deduped once per spelling. `InlineFieldInput` did not + * participate. Its `INLINE_ROUTED_FIELD_TYPES` set and its `isUserRef` branch + * are consulted at runtime against whatever type a STORED field actually + * carries, so a field still typed `owner` got a working person picker inline + * while the form refused it — two edit surfaces disagreeing about one field, + * which is worse than either uniform outcome, and the exact drift A′ was + * chartered to end. + * + * Contract now: inline edit meets the SAME loud refusal the form gives. The + * two ways of getting this wrong are pinned in both directions — a working + * picker (the pre-#4914 state) and a quiet degradation to the terminal + * plain-text input (what deleting the set member alone would have produced, + * since `isKnownFieldType('owner')` is false once the alias table drops it). + * + * Asserting the ENVELOPE, not merely "something changed": the refusal's + * testid + `data-retired-field-type` + the prescription text, because a + * refusal that does not name the offending spelling and its migration is not + * the disposition #4814 ruled for. + * + * The retired vocabulary is read from `@object-ui/fields`' live + * `RETIRED_FIELD_TYPES` table rather than restated here — one place per fact, + * the discipline #4814's own pins follow. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { RETIRED_FIELD_TYPES, resetRetiredFieldTypeReports } from '@object-ui/fields'; +import { + InlineFieldInput, + INLINE_PLAIN_TEXT_FIELD_TYPES, + INLINE_PLAIN_TEXT_INPUT_TESTID, + INLINE_ROUTED_FIELD_TYPES, +} from '../InlineFieldInput'; + +/** The one retired spelling, and the survivor it was a synonym for. */ +const RETIRED = 'owner'; +const SURVIVOR = 'user'; + +const dataSource = { find: vi.fn(async () => ({ data: [], total: 0 })) }; + +const renderInline = (field: Record, value: unknown = 'u-1') => + render( + , + ); + +let error: ReturnType; + +beforeEach(() => { + resetRetiredFieldTypeReports(); + error = vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('InlineFieldInput — a retired field-type spelling (objectui#4914)', () => { + it('is a spelling this repo really has retired', () => { + // Guards the premise of every assertion below: if the table stopped + // carrying `owner`, these would be testing nothing. + expect(Object.keys(RETIRED_FIELD_TYPES)).toContain(RETIRED); + }); + + it('renders the same visible refusal the record form gives, naming the spelling', () => { + renderInline({ name: 'record_owner', type: RETIRED }); + + const alert = screen.getByTestId('field-retired-tombstone'); + expect(alert.getAttribute('role')).toBe('alert'); + expect(alert.getAttribute('data-retired-field-type')).toBe(RETIRED); + // The prescription, not a bare "unsupported": an author must be able to + // read the migration off the refusal itself. + expect(alert.textContent).toContain("{ type: 'user', name: 'owner' }"); + expect(alert.textContent).toContain('objectui#4814'); + }); + + it('offers no editable control at all — neither the person picker nor a text box', () => { + const { container } = renderInline({ name: 'record_owner', type: RETIRED }); + + // Direction 1 — the pre-#4914 failure: a working `UserField` picker while + // the form refused the same field. + expect(screen.queryAllByRole('combobox')).toHaveLength(0); + expect(screen.queryAllByRole('button')).toHaveLength(0); + // Direction 2 — the failure a bare set-member deletion would have + // introduced: the terminal raw text input, which displays through + // `coerceToSafeValue` and SAVES a string over the stored value. + expect(screen.queryByTestId(INLINE_PLAIN_TEXT_INPUT_TESTID)).toBeNull(); + // Nothing an author could mistake for a working field. + expect(container.querySelector('input')).toBeNull(); + expect(container.querySelector('textarea')).toBeNull(); + }); + + it('writes the console prescription ONCE, not once per edited row', () => { + renderInline({ name: 'record_owner', type: RETIRED }); + renderInline({ name: 'other_owner', type: RETIRED }); + + const said = (error.mock.calls as unknown[][]) + .map((c) => String(c[0])) + .filter((m) => m.includes('objectui#4814')); + // The message is a fix instruction for an author, not a per-render event. + expect(said).toHaveLength(1); + }); + + it('holds an `$expand`-ed value the same way — the refusal is not value-shaped', () => { + // A stored `owner` field arriving expanded is exactly the case the picker + // branch existed for, so it is the one most likely to slip past a refusal + // keyed on anything but the type. + renderInline({ name: 'record_owner', type: RETIRED }, { id: 'u-1', name: 'Ada Lovelace' }); + expect(screen.getByTestId('field-retired-tombstone')).toBeInTheDocument(); + expect(screen.queryByTestId(INLINE_PLAIN_TEXT_INPUT_TESTID)).toBeNull(); + }); +}); + +describe('the `user` path is untouched — this is a subtraction of exactly one', () => { + it('still routes to a real editor and reaches no retirement machinery', () => { + renderInline({ name: 'assignee', type: SURVIVOR }); + + expect(screen.queryByTestId('field-retired-tombstone')).toBeNull(); + // A routed type must not land on the lossy fallback either. + expect(screen.queryByTestId(INLINE_PLAIN_TEXT_INPUT_TESTID)).toBeNull(); + expect( + (error.mock.calls as unknown[][]).map((c) => String(c[0])).join('\n'), + ).not.toContain('objectui#4814'); + }); + + it('leaves a genuinely unknown type on its pre-existing quiet fallback', () => { + // Retirement is a NAMED disposition, never a new blanket noise source. + renderInline({ name: 'weird', type: 'not-a-real-type' }, 'plain'); + expect(screen.queryByTestId('field-retired-tombstone')).toBeNull(); + expect(screen.getByTestId(INLINE_PLAIN_TEXT_INPUT_TESTID)).toBeInTheDocument(); + expect(error).not.toHaveBeenCalled(); + }); +}); + +describe('no retired spelling can re-enter the inline routing tables', () => { + it('the routed set carries no retired spelling', () => { + // Structural, and quantified over the whole table — so the NEXT retirement + // is covered here the day it lands, instead of needing this file edited. + const retiredButRouted = Object.keys(RETIRED_FIELD_TYPES).filter((t) => + INLINE_ROUTED_FIELD_TYPES.has(t), + ); + expect(retiredButRouted).toEqual([]); + // …and the survivor is still routed, so this is not a vacuous pass. + expect(INLINE_ROUTED_FIELD_TYPES.has(SURVIVOR)).toBe(true); + }); + + it('a retired spelling is not smuggled in as "benign" either', () => { + for (const retired of Object.keys(RETIRED_FIELD_TYPES)) { + // Benign would mean "its stored value is already a string, the terminal + // input is lossless" — a claim no retired spelling gets to make. + expect(INLINE_PLAIN_TEXT_FIELD_TYPES.has(retired)).toBe(false); + } + }); + + it('does NOT rely on the delegation gate, which still answers `owner` with a picker', () => { + // Measured while writing this file, and the reason the refusal branch is + // the load-bearing part of #4914 rather than the two deletions: + // `hasFieldEditWidget('owner')` is TRUE, because the fields package still + // maps `owner: UserField` in `EDIT_WIDGETS` + // (`packages/fields/src/FieldEditWidget.tsx` — a residual face #4914 does + // not enumerate). Had the routing-table member simply been deleted, the + // type would have reached the same person picker down the delegation road. + // + // That asymmetry is deliberately NOT asserted here in either direction: it + // is the other card's to fix, and a pin on today's value would go red on + // the very fix that card proposes. What IS pinned is the outcome, which + // holds either way — the refusal runs before both roads. + renderInline({ name: 'record_owner', type: RETIRED }); + expect(screen.getByTestId('field-retired-tombstone')).toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx b/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx index 8c8f303c9b..14dede0ac7 100644 --- a/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx +++ b/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx @@ -239,10 +239,14 @@ describe('inline-edit type coverage — every type has exactly one decision (#42 // 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. + // This snapshot is derived from `ALL_TYPES`, not from + // `INLINE_ROUTED_FIELD_TYPES`, so it could not see that the set itself + // still listed `'owner'` — a member consulted at runtime against a + // STORED field's type, which is why it was a live inconsistency rather + // than dead code. objectui#4914 removed it and gave inline edit the same + // loud refusal the form gives; that disposition is pinned by + // `InlineFieldInput.retiredFieldType.test.tsx`, which also asserts no + // retired spelling can re-enter this set. routed: [ 'address', 'audio', 'avatar', 'boolean', 'currency', 'date', 'datetime', 'file', 'geolocation', 'image', 'location', 'lookup', 'master_detail', diff --git a/packages/types/src/__tests__/owner-retired-contract-twins.test.ts b/packages/types/src/__tests__/owner-retired-contract-twins.test.ts new file mode 100644 index 0000000000..8c3c7d42d4 --- /dev/null +++ b/packages/types/src/__tests__/owner-retired-contract-twins.test.ts @@ -0,0 +1,126 @@ +/** + * 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 three PUBLISHED contract twins of the `owner` field type + * (objectui#4914, fast-follow to objectui#4814's ruling A′). + * + * #4814 retired the `owner` field-type spelling and shrank the three public + * DOC unions (`fields/user.mdx`, `filter-builder.mdx`, `report-schema.mdx`). + * Their CODE twins live here and were left blessing the word, so this package + * spent the interval telling an author "legal" for a spelling + * `@object-ui/fields` answers with a tombstone refusal — the declared ≠ + * enforced shape ADR-0049 targets, on a PUBLISHED surface: + * + * 1. `ReportFieldSchema.type` (`zod/reports.zod.ts`) — a runtime validator, so + * the contradiction was executable, not merely advisory. + * 2. `ReportField['type']` (`reports.ts`) — its TS twin; the two report faces + * move in lockstep and are never split. + * 3. `UserFieldMetadata['type']` (`field-types.ts`) — published `.d.ts` + * autocomplete, i.e. the surface an AI author copies from. + * + * Two kinds of assertion, deliberately: the Zod half is an accept-set SHRINK on + * a published validator, so it is pinned by the REFUSAL ENVELOPE (a `type`-path + * issue naming the offending value) rather than by a bare `success === false` — + * a schema that rejected the whole document for an unrelated reason would + * satisfy the weaker check. The two TS halves erase at runtime, so they are + * pinned with `@ts-expect-error`, which is real enforcement here only because + * `packages/types/tsconfig.test.json` is chained from this package's + * `type-check` script (#3009). + * + * The `user` survivor is asserted with a FULL green `safeParse`, not merely + * "no `type` issue": this is a value-level judgement, so anything less would + * pass while the shrink had quietly invalidated the replacement idiom too. + */ + +import { describe, it, expect } from 'vitest'; +import { ReportFieldSchema } from '../zod/reports.zod.js'; +import type { ReportField } from '../reports.js'; +import type { UserFieldMetadata } from '../field-types.js'; + +/** The retired spelling, and the survivor it was a zero-delta synonym for. */ +const RETIRED = 'owner'; +const SURVIVOR = 'user'; + +describe('ReportFieldSchema — the runtime twin refuses the retired spelling', () => { + it('rejects a report field authored as `type: "owner"`, on the `type` path', () => { + const result = ReportFieldSchema.safeParse({ name: 'owner', type: RETIRED }); + + expect(result.success).toBe(false); + if (result.success) return; + + // The envelope, not just the verdict: exactly one issue, on the `type` + // path, coded as a closed-vocabulary violation. A bare `success === false` + // would also pass if the document had been rejected for some unrelated + // reason, which is not what this pin is for. + // + // The offending value is deliberately NOT asserted to appear in the + // message: measured on zod 4, an `invalid_value` issue carries the ALLOWED + // `values` and no echo of the input, so an assertion on the received + // spelling would have been pinning a message this validator never emits. + // What the allow-list itself says is the stronger reading anyway, and it + // is asserted directly below. + const typeIssues = result.error.issues.filter((i) => i.path.join('.') === 'type'); + expect(typeIssues).toHaveLength(1); + expect(typeIssues[0].code).toBe('invalid_value'); + + // The shrink, read off the refusal: the retired spelling is gone from the + // offered vocabulary and the survivor is still in it. + const offered = (typeIssues[0] as { values?: unknown[] }).values ?? []; + expect(offered).not.toContain(RETIRED); + expect(offered).toContain(SURVIVOR); + }); + + it('still accepts the replacement idiom `{ type: "user", name: "owner" }` in full', () => { + // The whole point of the retirement is that the record-owner concept + // survives verbatim — the field NAME carries ownership, the type carries + // the rendering. A full green parse, because a value-level rule is what + // this pin guards. + const result = ReportFieldSchema.safeParse({ name: 'owner', label: 'Owner', type: SURVIVOR }); + expect(result.success).toBe(true); + }); + + it('shrank by exactly one member — the sibling vocabulary is untouched', () => { + // Guards the shrink from over-reaching: a hand-edited enum that dropped a + // neighbour would otherwise pass every assertion above. + for (const type of ['lookup', 'reference', 'master_detail', 'image', 'file', 'tags']) { + expect(ReportFieldSchema.safeParse({ name: 'f', type }).success).toBe(true); + } + }); +}); + +describe('the published TS twins no longer offer the retired spelling', () => { + it('`ReportField["type"]` rejects it at compile time', () => { + const legal: ReportField = { name: 'owner', type: SURVIVOR }; + expect(legal.type).toBe(SURVIVOR); + + const retired: ReportField = { + name: 'owner', + // @ts-expect-error `owner` was retired by objectui#4814 — write + // `{ type: 'user', name: 'owner' }` instead (objectui#4914). + type: RETIRED, + }; + // Referenced so the binding is not merely unused — the `@ts-expect-error` + // above is the actual assertion, enforced by `tsconfig.test.json`. + expect(retired.name).toBe('owner'); + }); + + it('`UserFieldMetadata["type"]` rejects it at compile time', () => { + const legal: UserFieldMetadata = { name: 'owner', type: SURVIVOR }; + expect(legal.type).toBe(SURVIVOR); + + const retired: UserFieldMetadata = { + name: 'owner', + // @ts-expect-error `owner` was retired by objectui#4814; this interface + // describes the person-picker family, not the retired spelling + // (objectui#4914). + type: RETIRED, + }; + expect(retired.name).toBe('owner'); + }); +}); diff --git a/packages/types/src/field-types.ts b/packages/types/src/field-types.ts index c82a117b68..9a6603ad38 100644 --- a/packages/types/src/field-types.ts +++ b/packages/types/src/field-types.ts @@ -578,16 +578,28 @@ export interface AutoNumberFieldMetadata extends BaseFieldMetadata { } /** - * User/Owner field metadata + * User field metadata — a lookup specialized to `sys_user`, rendered by the + * person picker. + * + * `type` offered `'user' | 'owner'` until objectui#4814 retired the `owner` + * spelling (ruling A′), carried onto the published twins by objectui#4914. + * `owner` was a synonym with zero behavioral delta (both resolved to the SAME + * `UserField` widget) and was never a member of `@objectstack/spec`'s closed + * `FieldType`, so no object schema could declare it. While it survived here, + * published `.d.ts` autocomplete kept offering a word `@object-ui/fields` + * answers with a tombstone refusal. The record-owner idiom survives verbatim + * as `{ type: 'user', name: 'owner' }` — the field NAME carries the ownership + * meaning, the type carries the widget. The interface keeps its name: it + * describes the person-picker family, not the retired spelling. */ export interface UserFieldMetadata extends BaseFieldMetadata { - type: 'user' | 'owner'; + type: 'user'; multiple?: boolean; /** * Picker UI variant. * - `'search'` — the search-first PeoplePicker (rich rows + selection tray). - * Default for `user`/`owner` fields. + * Default for `user` fields. * - `'default'` — the classic table-based record-picker dialog. */ picker?: 'search' | 'default'; diff --git a/packages/types/src/reports.ts b/packages/types/src/reports.ts index 7f8fcfac7c..b29dd14591 100644 --- a/packages/types/src/reports.ts +++ b/packages/types/src/reports.ts @@ -79,6 +79,15 @@ export interface ReportField { * When the report is bound to an object (`objectName`), the runtime should * auto-hydrate this from the corresponding ObjectField so authors don't * need to repeat type metadata. + * + * `'owner'` was a member until objectui#4814 retired that field-type + * spelling (ruling A′), carried onto the published twins by objectui#4914. + * This union is the `.d.ts` half of the contract — while it listed the word, + * editor autocomplete offered a spelling the renderer refuses with a + * tombstone. It moves in lockstep with its runtime twin + * `ReportFieldSchema.type` (`packages/types/src/zod/reports.zod.ts`); the two + * report faces are never split. Write an owner column as + * `{ type: 'user', name: 'owner' }`. */ type?: | 'string' @@ -102,7 +111,6 @@ export interface ReportField { | 'image' | 'file' | 'user' - | 'owner' | 'richtext' | 'html' | 'markdown' diff --git a/packages/types/src/zod/reports.zod.ts b/packages/types/src/zod/reports.zod.ts index ef429fc983..9c507a8ab9 100644 --- a/packages/types/src/zod/reports.zod.ts +++ b/packages/types/src/zod/reports.zod.ts @@ -37,6 +37,17 @@ export const ReportAggregationTypeSchema = z.enum(['sum', 'avg', 'min', 'max', ' /** * Report Field Schema + * + * `type` accepted `'owner'` until objectui#4814 retired that field-type + * spelling (ruling A′) and objectui#4914 carried the shrink onto the published + * contract twins. This enum is the RUNTIME half of that contract: while it + * still listed the word, a published validator answered "legal" for a spelling + * `@object-ui/fields` answers with a tombstone refusal — the declared ≠ + * enforced shape ADR-0049 targets. It moves in lockstep with its TS twin + * `ReportField['type']` (`packages/types/src/reports.ts`); the two report + * faces are never split. Report an owner column as `{ type: 'user', name: + * 'owner' }` — the field NAME carries the ownership meaning, the type carries + * the rendering. */ export const ReportFieldSchema = z.object({ name: z.string().describe('Field name/identifier'), @@ -46,7 +57,7 @@ export const ReportFieldSchema = z.object({ 'string', 'text', 'number', 'date', 'datetime', 'time', 'boolean', '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', ]) .optional()