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
36 changes: 36 additions & 0 deletions .changeset/21589-detail-entry-sort-field-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
'@objectstack/spec': minor
---

feat(spec)!: an `object-master-detail-form` detail entry's `sortField` is retired — the console derives the line-position field from the child object and reads no authored value (#21589)

Clause-②: no (narrowing)

<!-- adr-0087: registered object-master-detail-form-detail-sort-field-removed, object-master-detail-form-detail-sort-field-retired -->

**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings.

`ComponentPropsMap['object-master-detail-form'].details[].sortField` named the child field the line grid stamps with each line's position on drag-reorder. The console stopped reading it: the field it stamps is derived from the child object, and the pinned console crossed that change while the spec still declared the key. So an authored `sortField` went through `os validate` clean and was dropped, and a drag-reorder stamped the derived field, or none (ADR-0049 enforce-or-remove).

### FROM → TO

| before | what to write instead |
| --- | --- |
| `details: [{ childObject: 'crm_invoice_line', sortField: 'line_no' }]` | delete `sortField`. The grid stamps the child object's first field named `position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort`. |
| `sortField` naming a field outside that list | give the child object one of those fields; the line order is kept there. |
| an entry that names `relationshipField` and at least one column and gives every column a `type` | unchanged: the renderer keeps that entry exactly as authored, loads no child schema for it, and stamps no line position, before and after the upgrade alike. |

**The one-line fix: delete `sortField` from every `object-master-detail-form` detail entry.** `os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand.

**What an author now sees.** Writing the key fails `tsc` (its input type is the retired-key mark), and `os validate`, `os build` and `os lint` report it as a `component-props-invalid` warning carrying the prescription at `properties.details.N.sortField`. A page that carries it still saves and loads: a page component's `properties` is not parsed on the metadata save or load path.

### The retirement kit

- **Tombstone.** `sortField` is a `retiredKey()` on the strict detail entry. Its prescription prints the derived field names from their one declaration, a module reached by relative import only (`data/inline-grid-sort-fields.ts`), which the derived inline-grid columns read too.
- **D2 conversion `object-master-detail-form-detail-sort-field-removed`** (step 18, retired from the load path): a lossless delete of `sortField` from every `properties.details[]` entry of an `object-master-detail-form`, scoped by component type and by position. Stored `sys_metadata` pages and built artifacts replay it, one notice per entry.
- **D3 entry `object-master-detail-form-detail-sort-field-retired`** carries the judgment the delete cannot make: whether the child object declares the field the line order is kept in.
- **`RETIRED_KEYS_BY_MAJOR[18]`** registers the nested key `ui/ObjectMasterDetailFormProps:details.sortField`.
- **`record:line_items`' answer to `sortField`** no longer sends the author to the detail entry: no block takes an authored `sortField` any more.
- **No deprecation window**: the writer census is zero.

**Measured producers: none.** On origin/main 9a4182a752, no `object-master-detail-form` detail entry in `examples/`, `apps/`, `packages/`, `skills/` or `content/docs/` writes `sortField`, against the sibling detail-entry key `addLabel` on the showcase project workspace's entry as the control, through the same instrument. At the objectui pin `89cad75d5570` the only detail entries that write it are probes asserting that nothing reads it. Deployed metadata NOT MEASURED.
2 changes: 1 addition & 1 deletion content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1084,7 +1084,7 @@ Sort field and direction pair
| **formFields** | `string[]` | optional | Child field names for the per-row expand form. When omitted they are derived from the child object's fields — except on an entry that names both `relationshipField` and at least one column, which is kept as authored: nothing is derived, and the per-row form is offered only when `inlineMode` is 'form', where it draws the child object's full field list |
| **inlineMode** | `Enum<'grid' \| 'form'>` | optional | Inline-edit form factor: 'grid' = editable cells; 'form' = read-only list + per-row full form. When omitted it is resolved from the relationship field's `inlineEdit`, else from the child object's shape — except on an entry that names both `relationshipField` and at least one column, which is kept as authored: nothing is resolved, the collection renders as a grid, and the per-row form is offered only when `formFields` lists more fields than `columns` |
| **amountField** | `string` | optional | Numeric child column summed for the running total and the `totalField` rollup. When omitted it is picked from the grid's number and currency columns: a computed one, else one named `amount`, `total`, `subtotal`, `line_total`, `line_amount` or `net_amount`, else the last currency column, else the last numeric one — except on an entry that names `relationshipField` and at least one column and gives every column a `type`. The renderer keeps that entry exactly as authored, so nothing is picked. With no `amountField` authored or picked, the sums read a child column named `amount`, and the grid shows a running total only when `totalField` is set |
| **sortField** | `string` | optional | Child field holding the line sort position, stamped on drag-reorder. When omitted it is the child object's first field named `position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort`, if it has one — except on an entry that names `relationshipField` and at least one column and gives every column a `type`. The renderer keeps that entry exactly as authored: nothing is derived, the grid stamps no line position, and a drag-reorder is not saved |
| **sortField** | `never` | optional | [REMOVED] `object-master-detail-form` property `details[].sortField` was removed in @objectstack/spec 17 (ADR-0087 D2) — the console reads no authored value: the field the line grid stamps with each line's position on drag-reorder is derived from the child object, so an authored `sortField` was accepted and dropped. Delete the key. For a drag-reorder to be saved, give the child object a field named `position` / `sort_order` / `sequence` / `line_no` / `line_number` / `sort`: the renderer stamps the child's first field with one of those names — except on an entry that names `relationshipField` and at least one column and gives every column a `type`, which the renderer keeps exactly as authored and stamps no line position on. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
| **totalField** | `string` | optional | Parent field to receive the rolled-up sum |
| **title** | `string` | optional | Section title |
| **minRows** | `number` | optional | Minimum number of rows |
Expand Down
26 changes: 26 additions & 0 deletions packages/lint/src/validate-component-props.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,32 @@ describe('validateComponentProps — undeclared keys', () => {
}
});

// #21589 — an `object-master-detail-form` detail entry's `sortField` is a
// retiredKey tombstone, one array level down. The same door, the same
// warning: the finding names the entry's key, and the page is never refused.
it('reports a retired detail-entry `sortField` as a warning carrying the prescription, at the entry\'s key', () => {
const findings = validateComponentProps(
stackWith([
{
type: 'object-master-detail-form',
properties: {
objectName: 'invoice',
details: [
{ title: 'Payments', childObject: 'invoice_payment' },
{ title: 'Lines', childObject: 'invoice_line', sortField: 'line_no' },
],
},
},
]),
);
expect(findings).toHaveLength(1);
const [f] = findings;
expect(f.severity).toBe('warning');
expect(f.rule).toBe(COMPONENT_PROPS_INVALID);
expect(f.path).toBe('pages[0].regions[0].components[0].properties.details.1.sortField');
expect(f.message).toContain('`object-master-detail-form` property `details[].sortField` was removed in @objectstack/spec 17');
});

it('walks components nested inside `properties` (tabs items → children)', () => {
const findings = validateComponentProps(
stackWith([
Expand Down
203 changes: 203 additions & 0 deletions packages/spec/src/conversions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12473,6 +12473,208 @@ const dashboardWidgetChartConfigStructureRemoved: MetadataConversion = {
},
};

/**
* `object-master-detail-form`'s detail entry `sortField` leaves the contract
* (protocol 18, #21589 — ADR-0049 enforce-or-remove; the spec half of
* objectui#11070 round 9, the direction recorded on #21220's landing and
* mirrored on objectui#11396 ③: tombstone plus ADR-0087).
*
* **A pure lossless delete.** The console stopped reading the authored
* override at objectui `0a3e5409f`, and the `.objectui-sha` pin
* (`89cad75d5570`) is past it: `MasterDetailDetailConfig` has no `sortField`
* member (`plugin-form/src/MasterDetailForm.tsx:83`), and the field the line
* grid stamps with each line's position is the one `deriveDetail` derives from
* the child object (`deriveMasterDetail.ts:540`), handed to the grid as
* `sort_field` (`:874`). An authored value changed nothing on that console, so
* deleting it preserves observed behaviour exactly; the line order is kept by
* the child object's own field, which the entry's tombstone names. The
* tombstone refuses the key for a live author (advisory, via the props lint:
* `PageComponentSchema.properties` is an open bag).
*
* ⚠️ Scoped by component `type` and by POSITION — `properties.details[]` of an
* `object-master-detail-form` — never by key name: `sortField` is an ordinary
* name for an open-namespace component's own prop, and the fixture's
* non-carrier control is such a component authoring the same shape. A
* `details` entry that is not an object rides through untouched (the props
* gate reports it; it is not this entry's to fix).
*
* Zero authored occurrences in this repo's corpora at the retirement — no
* detail entry under `examples/`, `apps/`, `packages/`, `skills/` or
* `content/docs/` writes the key (control: the sibling detail-entry key
* `addLabel`, authored on the showcase project workspace's entry, same
* instrument, origin/main 9a4182a752) — so this entry exists for stored
* `sys_metadata` rows and for authors outside the repo, which the census could
* not measure.
*/
const objectMasterDetailFormDetailSortFieldRemoved: MetadataConversion = {
id: 'object-master-detail-form-detail-sort-field-removed',
toMajor: 18,
retiredFromLoadPath: true,
retiredAfter: '17.6.0',
surface: 'page.component.object-master-detail-form.details[].sortField',
summary:
"object-master-detail-form detail entry prop 'sortField' removed (the console reads no authored "
+ 'value: the line grid stamps the field it derives from the child object, so the key was '
+ "accepted and dropped; delete the key — the child object's own position field keeps the line order)",
apply(stack, emit) {
return mapPageComponents(stack, (component, path) => {
if (component.type !== 'object-master-detail-form') return component;
const properties = component.properties;
if (!isDict(properties) || !Array.isArray(properties.details)) return component;
const details = properties.details as unknown[];
let changed = false;
const nextDetails = details.map((entry, i) => {
if (!isDict(entry) || !('sortField' in entry)) return entry;
changed = true;
return stripKeys(entry, ['sortField'], emit, `${path}.properties.details[${i}]`);
});
if (!changed) return component;
return { ...component, properties: { ...properties, details: nextDetails } };
});
},
fixture: {
before: {
pages: [
{
name: 'invoice_entry',
regions: [
{
name: 'main',
components: [
// The carrier: a detail entry authoring the retired override,
// beside an entry WITHOUT it, which rides through untouched.
{
type: 'object-master-detail-form',
id: 'm1',
properties: {
objectName: 'crm_invoice',
details: [
{ childObject: 'crm_invoice_line', title: 'Lines', sortField: 'line_no' },
{ childObject: 'crm_invoice_payment', title: 'Payments' },
],
},
},
// ⚠️ The same shape on a component that is NOT an
// `object-master-detail-form` — its own prop, not this entry's
// key. Untouched: the strip is scoped by component type.
{
type: 'acme:line_editor',
id: 'x1',
properties: { details: [{ childObject: 'crm_invoice_line', sortField: 'position' }] },
},
// A block whose entries carry no `sortField`, and one with a
// non-object entry: both ride through untouched, by reference.
{
type: 'object-master-detail-form',
id: 'm2',
properties: { objectName: 'crm_order', details: [{ childObject: 'crm_order_line' }, 'crm_order_note'] },
},
// The nested position (#6775's lesson): a block inside a
// card's `children` is still a component.
{
type: 'page:card',
id: 'c1',
properties: {
children: [
{
type: 'object-master-detail-form',
id: 'm3',
properties: {
objectName: 'crm_quote',
details: [{ childObject: 'crm_quote_line', sortField: 'position' }],
},
},
],
},
},
],
},
],
},
// The named-slot shape (#6776): the block authored into a slotted page.
{
name: 'invoice_entry_detail',
kind: 'slotted',
regions: [],
slots: {
details: {
type: 'object-master-detail-form',
id: 'm4',
properties: {
objectName: 'crm_invoice',
details: [{ childObject: 'crm_invoice_line', sortField: 'sequence' }],
},
},
},
},
],
},
after: {
pages: [
{
name: 'invoice_entry',
regions: [
{
name: 'main',
components: [
{
type: 'object-master-detail-form',
id: 'm1',
properties: {
objectName: 'crm_invoice',
details: [
{ childObject: 'crm_invoice_line', title: 'Lines' },
{ childObject: 'crm_invoice_payment', title: 'Payments' },
],
},
},
{
type: 'acme:line_editor',
id: 'x1',
properties: { details: [{ childObject: 'crm_invoice_line', sortField: 'position' }] },
},
{
type: 'object-master-detail-form',
id: 'm2',
properties: { objectName: 'crm_order', details: [{ childObject: 'crm_order_line' }, 'crm_order_note'] },
},
{
type: 'page:card',
id: 'c1',
properties: {
children: [
{
type: 'object-master-detail-form',
id: 'm3',
properties: { objectName: 'crm_quote', details: [{ childObject: 'crm_quote_line' }] },
},
],
},
},
],
},
],
},
{
name: 'invoice_entry_detail',
kind: 'slotted',
regions: [],
slots: {
details: {
type: 'object-master-detail-form',
id: 'm4',
properties: { objectName: 'crm_invoice', details: [{ childObject: 'crm_invoice_line' }] },
},
},
},
],
},
// Three notices: the region-level entry, the nested one and the slotted
// one. The open-namespace sibling and the entries without the key emit none.
expectedNotices: 3,
},
};

/**
* `object.tenancy.organizationField` leaves the authorable surface (protocol
* 18, #19054 — ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18,
Expand Down Expand Up @@ -14598,6 +14800,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [
{ conversion: objectGridDefaultSortRemoved, order: 14 },
{ conversion: objectGridResizableColumnsRemoved, order: 57 },
{ conversion: objectKanbanQuickAddRemoved, order: 15 },
{ conversion: objectMasterDetailFormDetailSortFieldRemoved, order: 60 },
{ conversion: objectTenancyOrganizationFieldRemoved, order: 35 },
{ conversion: pageAssignedProfilesRemoved, order: 31 },
{ conversion: pageComponentFilterRecordToRuleArray, order: 36 },
Expand Down
10 changes: 5 additions & 5 deletions packages/spec/src/data/inline-grid-columns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@
* no expand control.
*/

// The sort-position names live in their own module, reached by relative
// import only: the retired `object-master-detail-form` detail entry
// `sortField`'s prescription prints the same list (`ui/component.zod.ts`).
import { INLINE_GRID_SORT_FIELDS } from './inline-grid-sort-fields';

/** Default-visible column budget of a derived inline grid; the rest are `defaultHidden`. */
export const DEFAULT_MAX_INLINE_GRID_COLUMNS = 6;

Expand All @@ -107,11 +112,6 @@ const INLINE_GRID_SYSTEM_FIELDS: ReadonlySet<string> = new Set([
'organization_id', 'tenant_id', 'space', 'owner',
]);

/** Field names that hold a line's sort position: the grid stamps them on drag-reorder. */
const INLINE_GRID_SORT_FIELDS: ReadonlySet<string> = new Set([
'position', 'sort_order', 'sequence', 'line_no', 'line_number', 'sort',
]);

/** Field types a line-item cell cannot edit. File-family types are absent: they render an upload cell. */
const INLINE_GRID_NON_EDITABLE_TYPES: ReadonlySet<unknown> = new Set([
'formula', 'summary', 'rollup', 'autonumber', 'auto_number',
Expand Down
Loading
Loading