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
35 changes: 35 additions & 0 deletions .changeset/retire-owner-widget-alias.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/components/complex/filter-builder.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion content/docs/core/report-schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 21 additions & 7 deletions content/docs/fields/user.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,19 @@ The User Field component provides a user selector for assigning users or owners

<SchemaExample id="fields-user/multiple-user-selection" />

## 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.

<SchemaExample id="fields-user/record-owner-read-only" />

## Field Schema

```plaintext
interface UserFieldSchema {
type: 'user' | 'owner';
type: 'user';
name: string; // Field name/ID
label?: string; // Field label
value?: User | User[]; // Selected user(s)
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
{
"name": "record_owner",
"label": "Owner",
"type": "owner",
"type": "user",
"readonly": true
}
]
Expand Down
5 changes: 4 additions & 1 deletion packages/app-shell/src/utils/paramToField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,10 @@ export function paramToField(param: ActionParamDef): Record<string, any> {
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,
Expand Down
5 changes: 4 additions & 1 deletion packages/app-shell/src/utils/paramValueShape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,10 @@ export const PARAM_VALUE_SHAPES: Readonly<Record<string, ParamValueShapeSpec>> =
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.' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 () => {
Expand Down
181 changes: 181 additions & 0 deletions packages/fields/src/__tests__/owner-retired.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<RetiredFieldTombstone field={{ type: RETIRED, name: 'record_owner' }} />);

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(<RetiredFieldTombstone field={{ type: RETIRED }} />);
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]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,9 @@ const WIDGETS: Record<string, ComponentType<any>> = {
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,
Expand Down
4 changes: 3 additions & 1 deletion packages/fields/src/__tests__/widget-dom-leak-e2e.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,9 @@ const WIDGETS: Record<string, ComponentType<any>> = {
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,
Expand Down
6 changes: 5 additions & 1 deletion packages/fields/src/field-type-alias.multiple.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading
Loading