Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .changeset/owner-retired-published-contract-twins.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 56 additions & 3 deletions packages/plugin-detail/src/InlineFieldInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -103,8 +105,28 @@ export const INLINE_ROUTED_FIELD_TYPES = new Set<string>([
'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',
Expand Down Expand Up @@ -226,6 +248,31 @@ export const InlineFieldInput: React.FC<InlineFieldInputProps> = ({
if (editWidget === 'permission-facet-link') {
return <PermissionFacetLink value={value} field={field as any} />;
}
// 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 <RetiredFieldTombstone field={field} />;
}
// 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
Expand Down Expand Up @@ -287,7 +334,13 @@ export const InlineFieldInput: React.FC<InlineFieldInputProps> = ({
// 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' ||
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, any>, value: unknown = 'u-1') =>
render(
<InlineFieldInput
field={field}
value={value}
onChange={vi.fn()}
dataSource={dataSource}
/>,
);

let error: ReturnType<typeof vi.spyOn>;

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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading