From a4ac1f38c28ce8c066dd99a4fe7c4d04ebaeee68 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 00:30:08 +0000 Subject: [PATCH 1/2] refactor(plugin-form,types): the master-detail form's `details` entry is the spec's closed entry, by reference (objectui#11396) `@objectstack/spec` 17.6.0 judges `ComponentPropsMap['object-master-detail-form'].details` as an array of closed entries (objectstack-ai/objectstack#21215): `childObject` required, `columns` the spec's inline grid column, an undeclared key refused by name. `@object-ui/types` already took the whole props row by reference, so both published zod faces give the spec's verdict on an entry; the TypeScript face in `@object-ui/plugin-form`, `MasterDetailDetailConfig`, restated the entry by hand. - `MasterDetailDetailConfig` is derived from the spec's entry type (its `z.input` authoring face) by a type-only import, minus the one member the renderer does not read: the spec still declares `sortField`, which objectui#11070 round 9 retired here; the omission is stated on the type and pinned, and the spec-side retirement stays objectstack's half. Member for member the type is the shape it was, so the published accept set does not move (empty-frontmatter changeset). - The `object-master-detail-form` registration declares `of: 'object'` for `details`, derived from the now single-kind member contract, and a description. - `masterDetailDetailsMembers-8071.test.tsx` holds the config `Equal` to the spec entry minus `sortField`, pins that the spec still declares `sortField`, and that a `columns` entry is the grid's `GridColumn`; its header no longer says the spec row is `z.array(z.unknown())`. - New `packages/types` pin: both faces refuse an undeclared entry key, a missing `childObject`, a bare-string column and an undeclared column key, accept `{ childObject }` and `sortField`, with one verdict across the two doors. - The console parity row for `object-master-detail-form.details` describes the closed entry, the derivation and the `of`. Claude-Session: https://claude.ai/code/session_01CPvhwGcirXqBGEdPSb72TZ Co-authored-by: Claude --- .../11396-master-detail-details-entry.md | 4 + .../registry-inputs-spec-parity.test.ts | 2 +- packages/plugin-form/src/MasterDetailForm.tsx | 88 +++++---- .../masterDetailDetailsMembers-8071.test.tsx | 59 ++++-- packages/plugin-form/src/index.tsx | 16 +- ...er-detail-form-details-entry-11396.test.ts | 174 ++++++++++++++++++ 6 files changed, 291 insertions(+), 52 deletions(-) create mode 100644 .changeset/11396-master-detail-details-entry.md create mode 100644 packages/types/src/__tests__/object-master-detail-form-details-entry-11396.test.ts diff --git a/.changeset/11396-master-detail-details-entry.md b/.changeset/11396-master-detail-details-entry.md new file mode 100644 index 0000000000..72409906cb --- /dev/null +++ b/.changeset/11396-master-detail-details-entry.md @@ -0,0 +1,4 @@ +--- +--- + +No release. `@object-ui/plugin-form`'s exported `MasterDetailDetailConfig` is now derived from `@objectstack/spec`'s `details` entry by reference (`ComponentPropsMap['object-master-detail-form'].details[]`, a closed shape since 17.6.0) instead of restating it by hand, minus the one member the renderer does not read (`sortField`, retired here by objectui#11070). Member for member the type is the same as before — the same keys, the same member types, `columns` the same inline grid column — so what a caller may write does not move. The `object-master-detail-form` registration declares `of: 'object'` and a description for `details`; tests and the console's parity prose follow (objectui#11396). diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 85a5635b1f..4a4ef36a8d 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -3034,7 +3034,7 @@ const MEMBER_PINS: Record = { }, 'object-master-detail-form.details': { file: 'packages/plugin-form/src/__tests__/masterDetailDetailsMembers-8071.test.tsx', - pins: 'Members are detail-collection OBJECTS (`MasterDetailDetailConfig`), and unlike this block\'s parent keys the read site is THIS block: `MasterDetailForm` is the key\'s only reader. Each member renders as its own section in AUTHORED order, headed by `title` (fallback `Line Items`), with `columns` reaching the line grid in authored order. ⭐ The sharp row: four members reach the grid through ONE hand-written object under the grid\'s camelCase keys (objectui#11610): `minRows`, `maxRows` and `addLabel` by the same name, and `amountField` RENAMED to the grid\'s `totalField` (the CHILD column summed; the detail\'s own `totalField`, the PARENT field, is not forwarded), beside `columns` and the DERIVED `sortField` (objectui#11070 round 9 retired the detail\'s `sortField` member; row 2c pins that a written one is read by nothing). That object is pinned as a SORTED KEY SET of exactly six, with two off-list members (one in the grid\'s own spelling, `allowAdd`) asserted not forwarded and an all-empty control beside it. `inlineMode: \'form\'` makes a list whose Add opens the full form (labelled `Add` when unset), and in grid mode `formFields` wider than `columns` offers the row form; each arm is the other\'s control. `childObject` and `relationshipField` address the atomic batch (each line a create on the child, linked by `{ $ref: 0 }`) and the edit-mode read (`{ $filter: { FK: recordId }, $top: 500 }` per collection). `totalField` receives the sum of the lines\' `amountField` on the PARENT leg, and a collection without one puts nothing there. With `{ childObject }` alone the FK and the columns are DERIVED from the child object, the one member behaviour the spec\'s description promises, and the batch writes the derived FK. `details: []` is the non-vacuity control: no section, no grid, a one-leg batch. The line grid is the REAL `LineItemsField`, wrapped only to record the props it is handed. The spec row is `z.array(z.unknown())`, whose description names five of the eleven members the renderer reads, and the registration declares no `of`, so the read site is the whole member contract. New file (objectui#8071 slice 18).', + pins: 'Members are detail-collection OBJECTS (`MasterDetailDetailConfig`), and unlike this block\'s parent keys the read site is THIS block: `MasterDetailForm` is the key\'s only reader. Each member renders as its own section in AUTHORED order, headed by `title` (fallback `Line Items`), with `columns` reaching the line grid in authored order. ⭐ The sharp row: four members reach the grid through ONE hand-written object under the grid\'s camelCase keys (objectui#11610): `minRows`, `maxRows` and `addLabel` by the same name, and `amountField` RENAMED to the grid\'s `totalField` (the CHILD column summed; the detail\'s own `totalField`, the PARENT field, is not forwarded), beside `columns` and the DERIVED `sortField` (objectui#11070 round 9 retired the detail\'s `sortField` member; row 2c pins that a written one is read by nothing). That object is pinned as a SORTED KEY SET of exactly six, with two off-list members (one in the grid\'s own spelling, `allowAdd`) asserted not forwarded and an all-empty control beside it. `inlineMode: \'form\'` makes a list whose Add opens the full form (labelled `Add` when unset), and in grid mode `formFields` wider than `columns` offers the row form; each arm is the other\'s control. `childObject` and `relationshipField` address the atomic batch (each line a create on the child, linked by `{ $ref: 0 }`) and the edit-mode read (`{ $filter: { FK: recordId }, $top: 500 }` per collection). `totalField` receives the sum of the lines\' `amountField` on the PARENT leg, and a collection without one puts nothing there. With `{ childObject }` alone the FK and the columns are DERIVED from the child object, the one member behaviour the spec\'s description promises, and the batch writes the derived FK. `details: []` is the non-vacuity control: no section, no grid, a one-leg batch. The line grid is the REAL `LineItemsField`, wrapped only to record the props it is handed. Since `@objectstack/spec` 17.6.0 (objectstack-ai/objectstack#21215) the spec row is an array of CLOSED entries — `childObject` required, `columns` the spec\'s inline grid column, an undeclared member key refused by name — and `@object-ui/types` takes that row by reference, so both published faces give the spec\'s verdict on an entry (`packages/types/src/__tests__/object-master-detail-form-details-entry-11396.test.ts`). `MasterDetailDetailConfig` is DERIVED from that entry (objectui#11396) and held `Equal` to it at compile time in the pinned file, with ONE stated fork: the spec declares `sortField`, which this block does not read (row 2c), and the spec-side retirement is objectstack\'s. The registration declares `of: \'object\'`, derived from the now single-kind member contract. A declaration fixes an entry\'s SHAPE, not what this block DOES with a member, so the read site is still the whole member contract. New file (objectui#8071 slice 18).', }, 'object-master-detail-form.fields': { file: 'packages/plugin-form/src/__tests__/topLevelFieldsWarnCoverage-8847.test.tsx', diff --git a/packages/plugin-form/src/MasterDetailForm.tsx b/packages/plugin-form/src/MasterDetailForm.tsx index ac13be28ab..2fa79a3713 100644 --- a/packages/plugin-form/src/MasterDetailForm.tsx +++ b/packages/plugin-form/src/MasterDetailForm.tsx @@ -33,7 +33,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { BatchTransactionOperation, DataSource, GridFieldMetadata, I18nLabel } from '@object-ui/types'; import { runBatchTransaction } from '@object-ui/core'; -import { LineItemsField, type GridColumn } from '@object-ui/fields'; +import { LineItemsField } from '@object-ui/fields'; import { Button, Card, CardContent, CardHeader, CardTitle, cn, toast } from '@object-ui/components'; import { formatDisplayNumber, @@ -59,40 +59,58 @@ import { type ChildSchema, } from './masterDetailTx'; import { isSameStoredValue } from './sanitize'; -import { deriveDetail, type InlineMode } from './deriveMasterDetail'; - -export interface MasterDetailDetailConfig { - /** Child object name, e.g. 'expense_line'. */ - childObject: string; - /** FK field on the child pointing back to the parent, e.g. 'expense_claim'. - * Optional — auto-detected from the child's master_detail/lookup field that - * references the parent object when omitted. */ - relationshipField?: string; - /** Editable columns for the child grid. Optional — derived from the child - * object's fields (via DataSource.getObjectSchema) when omitted. */ - columns?: GridColumn[]; - /** Field names for the per-row expand form. Optional — derived from the child - * object's fields (broader than `columns`: includes rich types) when omitted. */ - formFields?: string[]; - /** Inline-edit form factor: 'grid' = editable cells; 'form' = read-only list + - * per-row full form. Optional — resolved from the relationship's `inlineEdit` - * (incl. the smart default) when omitted. */ - inlineMode?: InlineMode; - /** Numeric child column to sum, e.g. 'amount'. */ - amountField?: string; - // ⛔ No `sortField` member. The child field the grid stamps with each line's - // position is DERIVED from the child object (`deriveDetail`: its `position` / - // `sort_order` / … field) and carried on the resolved entry, never authored: - // objectui#11070 round 9 retired the authored override, which nothing wrote - // in either repository and no spec key carries. - /** Parent field to receive the rolled-up sum, e.g. 'total_amount'. */ - totalField?: string; - /** Section title. */ - title?: string; - minRows?: number; - maxRows?: number; - addLabel?: string; -} +import { deriveDetail } from './deriveMasterDetail'; +// objectui#11396 — one `details` entry, by reference: the spec's own type for +// `ComponentPropsMap['object-master-detail-form'].details[]`. Type-only. +import type { ObjectMasterDetailFormProps } from '@objectstack/spec/ui'; + +/** + * One detail collection: the `details` entry of + * `ComponentPropsMap['object-master-detail-form']` in `@objectstack/spec`, on + * its authoring face (`z.input`, what a document writes), taken by reference + * rather than restated here (objectui#11396). Since 17.6.0 the spec judges that + * entry as a closed shape (objectstack-ai/objectstack#21215): `childObject` + * required, every other member optional, `columns` the spec's inline grid + * column (the type the line grid's `GridColumn` is), an undeclared key refused + * by name. `@object-ui/types` takes the whole props row by reference, so + * `objectui validate` gives the spec's verdict on an entry; this is the + * TypeScript face of that entry, and + * `__tests__/masterDetailDetailsMembers-8071.test.tsx` holds the two equal at + * compile time. + * + * ⛔ ONE fork, stated rather than absorbed: the spec entry declares `sortField`, + * and this form reads no such member. objectui#11070 round 9 retired the + * authored override: the child field the grid stamps with each line's position + * is DERIVED from the child object (`deriveDetail` picks its `position` / + * `sort_order` / … field) and carried on the resolved entry, and row 2c of that + * test pins that a written `sortField` is read by nothing. The spec-side + * retirement is objectstack's half (recorded on + * objectstack-ai/objectstack#6017); until it lands, the member is left OFF this + * face, so the authoring face declares no key the renderer does not honour. + * That omission is the whole of what is hand-written about the shape. + * + * Where each member lands — the renderer's reading, which the spec's + * description does not carry: + * - `childObject` names the child object; `relationshipField` is the FK on the + * child back to the parent, auto-detected from the child's `master_detail` / + * `lookup` field that references the parent when omitted. + * - `columns` are the line grid's editable columns, derived from the child's + * fields (via `DataSource.getObjectSchema`) when omitted; `formFields` are + * the per-row expand form's field names (broader than `columns`: rich types + * included), derived likewise. + * - `inlineMode`: `grid` = editable cells, `form` = read-only list + per-row + * full form; resolved from the relationship's `inlineEdit` (incl. the smart + * default) when omitted. + * - `amountField` is the CHILD column summed, and reaches the grid as its + * `totalField`; `totalField` is the PARENT field the sum is saved to, and is + * not forwarded to the grid. + * - `title` heads the collection's section (`Line Items` when unset); + * `minRows`, `maxRows` and `addLabel` reach the grid under the same names. + */ +export type MasterDetailDetailConfig = Omit< + NonNullable[number], + 'sortField' +>; export interface MasterDetailFormSchema { type?: 'object-master-detail-form'; diff --git a/packages/plugin-form/src/__tests__/masterDetailDetailsMembers-8071.test.tsx b/packages/plugin-form/src/__tests__/masterDetailDetailsMembers-8071.test.tsx index 2c69f236b6..ed817dd83c 100644 --- a/packages/plugin-form/src/__tests__/masterDetailDetailsMembers-8071.test.tsx +++ b/packages/plugin-form/src/__tests__/masterDetailDetailsMembers-8071.test.tsx @@ -15,16 +15,21 @@ * `MasterDetailForm` is its only reader. A member is one detail collection, * typed `MasterDetailDetailConfig`. * - * Nothing declared says what a member is. The registration is a bare - * `{ name: 'details', type: 'array', required: true }` with no `of` and no - * description. The spec row is `z.array(z.unknown())`, so every member parses, - * and its description names five members (`title`, `childObject`, `addLabel`, - * `columns`, `relationshipField`). The renderer reads every member - * `MasterDetailDetailConfig` declares, eleven since objectui#11070 round 9 - * retired `sortField`; the compile-time block at the end of this file holds - * that member list, so the count is re-derived by the compiler rather than - * by this sentence. So the read site is the whole member contract, and every - * row below is measured there. + * What a member IS is declared on both sides, and both declarations are the + * spec's since `@objectstack/spec` 17.6.0 (objectstack-ai/objectstack#21215, + * objectui#11396): the spec row is an array of CLOSED entries — `childObject` + * required, an undeclared key refused by name, `columns` the spec's inline grid + * column — and `MasterDetailDetailConfig` is derived from that entry by + * reference, with ONE stated fork: the spec declares `sortField`, which this + * block does not read (objectui#11070 round 9 retired the authored override; + * row 2c below pins that a written one reaches nothing). The registration + * declares `of: 'object'`, derived from that single-kind member contract. The + * compile-time block at the end of this file holds the config equal to the + * spec's entry minus that fork and spells the member list out, so the member + * count is re-derived by the compiler rather than by this sentence. A + * declaration fixes an entry's SHAPE, not what this block DOES with a member, + * so the read site is still the whole member contract, and every row below is + * measured there. * * ## Where each member lands * @@ -99,7 +104,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import React from 'react'; -import { registerAllFields } from '@object-ui/fields'; +import { registerAllFields, type GridColumn } from '@object-ui/fields'; +import type { ObjectMasterDetailFormProps } from '@objectstack/spec/ui'; import { MasterDetailForm, type MasterDetailDetailConfig } from '../MasterDetailForm'; /** @@ -472,9 +478,32 @@ describe('`object-master-detail-form` — the member shape of `details`', () => /** Exact type equality: `true` only when A and B are the same type. */ type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +/** The spec's own `details` entry, on its authoring face (objectui#11396). */ +type SpecDetailEntry = NonNullable[number]; + +/** + * The config IS the spec's entry minus the one stated fork, by reference + * (objectui#11396): a member the spec adds, drops or retypes lands on the + * config with no hand edit, and a hand-written restatement that drifts is red + * here before it is red anywhere else. + */ +const derivedFromSpec: Equal> = true; + +/** + * The fork is still open upstream: the spec entry declares `sortField`. When + * this row reds, objectstack retired the member (objectstack-ai/objectstack#6017 + * records that half), and the `Omit` in `MasterDetailForm.tsx` is to be dropped + * together with this row and the premise of row 2c and `retiredSortField`. + */ +const specStillDeclaresSortField: Equal = true; + +/** A `columns` entry is the spec's inline grid column, the type the line grid reads. */ +const columnsAreTheGridColumn: Equal[number], GridColumn> = true; + /** - * Every member a detail can be authored with: the list the header's count is - * read from. `sortField` is not on it (objectui#11070 round 9). + * Every member a detail can be authored with, spelled out: the spec entry's + * keys minus `sortField` (objectui#11070 round 9). A key the spec adds is a + * red row here, never a silent widening of what this block is said to read. */ const memberList: Equal< keyof MasterDetailDetailConfig, @@ -493,6 +522,6 @@ const memberList: Equal< /** The lit control for the directive below: the same literal without the retired member compiles. */ const authoredDetail: MasterDetailDetailConfig = { childObject: 'po_step', amountField: 'qty' }; -// @ts-expect-error objectui#11070 round 9: `sortField` is retired as a detail member; the sort field is derived from the child object. +// @ts-expect-error objectui#11070 round 9: `sortField` is retired as a detail member; the sort field is derived from the child object. The spec still declares it (`specStillDeclaresSortField` above), and this face leaves it off on purpose (objectui#11396). const retiredSortField: MasterDetailDetailConfig = { childObject: 'po_step', sortField: 'line_no' }; -void [memberList, authoredDetail, retiredSortField]; +void [derivedFromSpec, specStillDeclaresSortField, columnsAreTheGridColumn, memberList, authoredDetail, retiredSortField]; diff --git a/packages/plugin-form/src/index.tsx b/packages/plugin-form/src/index.tsx index 6b3f6736df..cb58d90ff5 100644 --- a/packages/plugin-form/src/index.tsx +++ b/packages/plugin-form/src/index.tsx @@ -547,7 +547,21 @@ ComponentRegistry.register('object-master-detail-form', MasterDetailFormRenderer }, { name: 'mode', type: 'enum', enum: ['create', 'edit'] }, { name: 'sections', type: 'array' }, - { name: 'details', type: 'array', required: true }, + // `of: 'object'` is DERIVED from the contract, as `ComponentInput.of` + // prescribes (objectui#11396): since `@objectstack/spec` 17.6.0 the row's + // `details` member is a closed object entry (objectstack-ai/objectstack#21215), + // the one coarse kind the spec accepts there, and the console parity gate's + // member direction witnesses the declaration against the spec row. The + // entry's TypeScript face is `MasterDetailDetailConfig`, derived from the + // same spec entry by reference (MasterDetailForm.tsx). + { + name: 'details', + type: 'array', + of: 'object', + required: true, + description: + 'The child collections, one OBJECT per entry, judged by the spec\'s closed `details` entry: `childObject` (required) names the child object; `relationshipField` is the FK on the child back to the parent, derived from the child\'s metadata when omitted; `columns` are the line grid\'s columns as the spec\'s inline grid column objects (`{ name, … }`, never a bare field name), derived from the child\'s fields when omitted; `formFields` names the per-row form\'s fields; `inlineMode` is `grid` (editable cells) or `form` (list + per-row form); `amountField` is the CHILD column summed and `totalField` the PARENT field that sum is saved to; `title`, `minRows`, `maxRows` and `addLabel` dress the section and its grid. An undeclared member key is refused by name. The entry\'s `sortField` is declared by the spec and read by nothing here: the line-position field is derived from the child object (objectui#11070).', + }, { name: 'recordId', type: 'string', description: 'The parent record to load in `edit` mode. Leave unset for `create`.' }, // TWO values, not the six `object-form` declares (objectui#5939). A bare // `string` here let an out-of-vocabulary value match NO renderer branch and diff --git a/packages/types/src/__tests__/object-master-detail-form-details-entry-11396.test.ts b/packages/types/src/__tests__/object-master-detail-form-details-entry-11396.test.ts new file mode 100644 index 0000000000..459cb64b37 --- /dev/null +++ b/packages/types/src/__tests__/object-master-detail-form-details-entry-11396.test.ts @@ -0,0 +1,174 @@ +/** + * 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. + */ + +/** + * objectui#11396 — an `object-master-detail-form` `details` entry is judged by + * the spec's own closed entry, on both published faces, by reference. + * + * `@objectstack/spec` 17.6.0 judges `ComponentPropsMap['object-master-detail-form'] + * .details` as an array of closed entries (objectstack-ai/objectstack#21215): + * `childObject` required, every other member optional, `columns` the spec's + * inline grid column, an undeclared key refused by name. This package's + * `ObjectMasterDetailFormBlockSchema` takes the whole props row through + * `stripImportedDefaults(SpecObjectMasterDetailFormPropsSchema)` (objectui#10859 + * batch 2), so it needed no edit for the entry to be judged — which is exactly + * the claim that goes quiet if nobody pins it. This file pins it, so a later + * hand-mirrored `details` here, or a spec change to the entry, is red. + * + * ## What is pinned + * + * - BY REFERENCE, at the type level: the bag's `details` entry IS the spec's + * entry (judged by `tsc -p tsconfig.test.json`). + * - THE VERDICTS, on the tolerant face (`safeValidateSchema`, which + * `objectui validate` runs) and on the strict authoring face, for a node + * authored in the spec's `{ type, properties }` form: an undeclared entry + * key is refused at the entry with the key named; an entry with no + * `childObject` is refused at that member; a bare field-name column is + * refused at the column; a column with an undeclared key is refused at the + * column with the key named; `{ childObject }` alone is accepted — the lit + * control. + * - ONE VERDICT ACROSS THE TWO DOORS: each probe gets the verdict the spec's + * own props row gives it, read live in the same run, at the same path under + * `properties`. + * + * ## The fork, pinned as ACCEPTED so that closing it is a deliberate change + * + * The spec entry declares `sortField`; `MasterDetailForm` reads no such member + * (objectui#11070 round 9 retired the authored override, and + * `plugin-form`'s `masterDetailDetailsMembers-8071.test.tsx` row 2c pins that a + * written one reaches nothing). So `objectui validate` ACCEPTS an entry carrying + * `sortField` while the renderer ignores it. The spec-side retirement is + * objectstack's half (objectstack-ai/objectstack#6017); the row below records + * today's verdict, and reds the day the spec retires the key, which is the cue + * to flip it and drop the `Omit` on `MasterDetailDetailConfig`. + */ + +import { describe, it, expect } from 'vitest'; +import { + ObjectMasterDetailFormPropsSchema as SpecObjectMasterDetailFormPropsSchema, + type ObjectMasterDetailFormProps as SpecObjectMasterDetailFormProps, +} from '@objectstack/spec/ui'; +import type { z } from 'zod'; +import { ObjectMasterDetailFormBlockSchema } from '../zod/objectql.zod.js'; +import { safeValidateSchema, StrictAnyComponentSchema } from '../zod/index.zod.js'; + +/* ── Type level ───────────────────────────────────────────────────────────── */ + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +/** The spec's `details` entry on its authoring face. */ +type SpecDetailEntry = NonNullable[number]; +/** The same position on this package's block, read off its `properties` bag. */ +type MirrorDetailEntry = NonNullable< + NonNullable['properties']>['details'] +>[number]; + +export type detailsEntryIsTheSpecs = [ + Expect>, + // Non-vacuity: the comparison can fail. + Expect>, false>>, +]; + +/* ── Runtime ──────────────────────────────────────────────────────────────── */ + +type Issue = { code: string; path: readonly PropertyKey[]; keys?: readonly string[] }; + +/** A node authored in the spec's `{ type, properties }` form, with ONE detail entry. */ +const nodeWith = (entry: unknown) => ({ + type: 'object-master-detail-form', + properties: { objectName: 'po', details: [entry] }, +}); + +type Face = { name: string; parse: (doc: unknown) => { success: boolean; error?: { issues: Issue[] } } }; +const FACES: Face[] = [ + { name: 'tolerant (`safeValidateSchema`, which `objectui validate` runs)', parse: (doc) => safeValidateSchema(doc) as never }, + { name: 'strict authoring face', parse: (doc) => StrictAnyComponentSchema.safeParse(doc) as never }, +]; + +/** The issues a refused parse raised AT or UNDER one path, with that prefix stripped. */ +function issuesUnder(result: ReturnType, prefix: readonly PropertyKey[]): Issue[] { + if (result.success) return []; + return (result.error?.issues ?? []) + .filter((issue) => prefix.every((segment, i) => issue.path[i] === segment)) + .map((issue) => ({ ...issue, path: issue.path.slice(prefix.length) })); +} + +const PROBES = [ + { name: '`{ childObject }` alone (the lit control)', entry: { childObject: 'po_line' } }, + { name: 'an undeclared entry key', entry: { childObject: 'po_line', bogusKey: 1 } }, + { name: 'no `childObject`', entry: { title: 'Lines' } }, + { name: 'a bare field-name column', entry: { childObject: 'po_line', columns: ['qty'] } }, + { name: 'a column with an undeclared key', entry: { childObject: 'po_line', columns: [{ name: 'qty', bogusKey: 1 }] } }, + { name: 'the fork: `sortField`, declared by the spec and read by nothing', entry: { childObject: 'po_line', sortField: 'line_no' } }, +] as const; + +describe('an `object-master-detail-form` `details` entry is judged by the spec\'s closed entry (objectui#11396)', () => { + describe.each(FACES)('on the $name', ({ parse }) => { + it('accepts `{ childObject }` alone — the lit control', () => { + const result = parse(nodeWith({ childObject: 'po_line' })); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + }); + + it('refuses an undeclared entry key at the entry, naming the key', () => { + const result = parse(nodeWith({ childObject: 'po_line', bogusKey: 1 })); + expect(result.success).toBe(false); + const atEntry = issuesUnder(result, ['properties', 'details', 0]); + expect(atEntry.map((issue) => [issue.code, issue.path, issue.keys])).toEqual([ + ['unrecognized_keys', [], ['bogusKey']], + ]); + }); + + it('refuses an entry with no `childObject`, at that member', () => { + const result = parse(nodeWith({ title: 'Lines' })); + expect(result.success).toBe(false); + const atEntry = issuesUnder(result, ['properties', 'details', 0]); + expect(atEntry.map((issue) => [issue.code, issue.path])).toEqual([['invalid_type', ['childObject']]]); + }); + + it('refuses a bare field-name column at the column', () => { + const result = parse(nodeWith({ childObject: 'po_line', columns: ['qty'] })); + expect(result.success).toBe(false); + const atColumn = issuesUnder(result, ['properties', 'details', 0, 'columns', 0]); + expect(atColumn.map((issue) => issue.code)).toEqual(['invalid_type']); + }); + + it('refuses a column with an undeclared key at the column, naming the key', () => { + const result = parse(nodeWith({ childObject: 'po_line', columns: [{ name: 'qty', bogusKey: 1 }] })); + expect(result.success).toBe(false); + const atColumn = issuesUnder(result, ['properties', 'details', 0, 'columns', 0]); + expect(atColumn.map((issue) => [issue.code, issue.path, issue.keys])).toEqual([ + ['unrecognized_keys', [], ['bogusKey']], + ]); + }); + + it('ACCEPTS `sortField` — the fork the spec still declares and the renderer does not read (objectstack\'s half)', () => { + // When this reds, the spec retired the key: flip this row and drop the + // `Omit` on `MasterDetailDetailConfig` in `@object-ui/plugin-form`. + const result = parse(nodeWith({ childObject: 'po_line', sortField: 'line_no' })); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + }); + }); + + it.each(PROBES)('one verdict across the two doors — $name', ({ entry }) => { + const spec = SpecObjectMasterDetailFormPropsSchema.safeParse(nodeWith(entry).properties); + const mirror = safeValidateSchema(nodeWith(entry)) as ReturnType; + expect(mirror.success).toBe(spec.success); + // The same codes at the same paths, the mirror's under `properties`. + const specIssues = (spec.success ? [] : spec.error.issues).map((issue) => [issue.code, issue.path]); + const mirrorIssues = issuesUnder(mirror, ['properties']).map((issue) => [issue.code, issue.path]); + expect(mirrorIssues).toEqual(specIssues); + }); + + it('the probe table is not vacuous — both verdicts occur', () => { + const verdicts = PROBES.map(({ entry }) => safeValidateSchema(nodeWith(entry)).success); + expect(verdicts).toContain(true); + expect(verdicts).toContain(false); + }); +}); From 999aecb65710d0f57cdc326fbc65524ff22721ef Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 00:59:25 +0000 Subject: [PATCH 2/2] fix(plugin-form,types): the `details` entry rows hold on objectstack main too; the `sortField` fork is stated as retired upstream (objectui#11396, patch round 1) The `Spec Main Shape Gate` compiles this repository against `@objectstack/spec` built from objectstack `main`, where the detail entry's `sortField` is a `retiredKey()` tombstone (objectstack-ai/objectstack#21589, PR #21632, `6ec54f00`, unreleased after 17.6.0). The compile-time row `specStillDeclaresSortField` pinned the 17.6.0 shape and was red there. - Drop `specStillDeclaresSortField`. `derivedFromSpec`, `columnsAreTheGridColumn` and `memberList` hold on both shapes: the `Omit` subtracts the key whether the entry declares it as a string or as a tombstone. The forward tripwire for the retirement stays the runtime pin in `@object-ui/types`, which records `sortField` ACCEPTED on 17.6.0 and flips at the bump. - Say what is true now in the `MasterDetailForm.tsx` docblock, the members test header, the types pin header and row name, the registration description, the console parity row and the changeset body: the retirement has landed on objectstack `main`, is unreleased after 17.6.0, and the fork closes at objectui's bump to the first release carrying it. Claude-Session: https://claude.ai/code/session_01CPvhwGcirXqBGEdPSb72TZ Co-authored-by: Claude --- .../11396-master-detail-details-entry.md | 2 +- .../registry-inputs-spec-parity.test.ts | 2 +- packages/plugin-form/src/MasterDetailForm.tsx | 25 +++++++++------ .../masterDetailDetailsMembers-8071.test.tsx | 32 +++++++++++-------- packages/plugin-form/src/index.tsx | 2 +- ...er-detail-form-details-entry-11396.test.ts | 25 +++++++++------ 6 files changed, 52 insertions(+), 36 deletions(-) diff --git a/.changeset/11396-master-detail-details-entry.md b/.changeset/11396-master-detail-details-entry.md index 72409906cb..6b6b4ef2d1 100644 --- a/.changeset/11396-master-detail-details-entry.md +++ b/.changeset/11396-master-detail-details-entry.md @@ -1,4 +1,4 @@ --- --- -No release. `@object-ui/plugin-form`'s exported `MasterDetailDetailConfig` is now derived from `@objectstack/spec`'s `details` entry by reference (`ComponentPropsMap['object-master-detail-form'].details[]`, a closed shape since 17.6.0) instead of restating it by hand, minus the one member the renderer does not read (`sortField`, retired here by objectui#11070). Member for member the type is the same as before — the same keys, the same member types, `columns` the same inline grid column — so what a caller may write does not move. The `object-master-detail-form` registration declares `of: 'object'` and a description for `details`; tests and the console's parity prose follow (objectui#11396). +No release. `@object-ui/plugin-form`'s exported `MasterDetailDetailConfig` is now derived from `@objectstack/spec`'s `details` entry by reference (`ComponentPropsMap['object-master-detail-form'].details[]`, a closed shape since 17.6.0) instead of restating it by hand, minus the one member the renderer does not read (`sortField`: retired here by objectui#11070, and on objectstack `main` by objectstack-ai/objectstack#21589, unreleased after 17.6.0; the subtraction holds on both spec shapes). Member for member the type is the same as before — the same keys, the same member types, `columns` the same inline grid column — so what a caller may write does not move. The `object-master-detail-form` registration declares `of: 'object'` and a description for `details`; tests and the console's parity prose follow (objectui#11396). diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 4a4ef36a8d..9423e95109 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -3034,7 +3034,7 @@ const MEMBER_PINS: Record = { }, 'object-master-detail-form.details': { file: 'packages/plugin-form/src/__tests__/masterDetailDetailsMembers-8071.test.tsx', - pins: 'Members are detail-collection OBJECTS (`MasterDetailDetailConfig`), and unlike this block\'s parent keys the read site is THIS block: `MasterDetailForm` is the key\'s only reader. Each member renders as its own section in AUTHORED order, headed by `title` (fallback `Line Items`), with `columns` reaching the line grid in authored order. ⭐ The sharp row: four members reach the grid through ONE hand-written object under the grid\'s camelCase keys (objectui#11610): `minRows`, `maxRows` and `addLabel` by the same name, and `amountField` RENAMED to the grid\'s `totalField` (the CHILD column summed; the detail\'s own `totalField`, the PARENT field, is not forwarded), beside `columns` and the DERIVED `sortField` (objectui#11070 round 9 retired the detail\'s `sortField` member; row 2c pins that a written one is read by nothing). That object is pinned as a SORTED KEY SET of exactly six, with two off-list members (one in the grid\'s own spelling, `allowAdd`) asserted not forwarded and an all-empty control beside it. `inlineMode: \'form\'` makes a list whose Add opens the full form (labelled `Add` when unset), and in grid mode `formFields` wider than `columns` offers the row form; each arm is the other\'s control. `childObject` and `relationshipField` address the atomic batch (each line a create on the child, linked by `{ $ref: 0 }`) and the edit-mode read (`{ $filter: { FK: recordId }, $top: 500 }` per collection). `totalField` receives the sum of the lines\' `amountField` on the PARENT leg, and a collection without one puts nothing there. With `{ childObject }` alone the FK and the columns are DERIVED from the child object, the one member behaviour the spec\'s description promises, and the batch writes the derived FK. `details: []` is the non-vacuity control: no section, no grid, a one-leg batch. The line grid is the REAL `LineItemsField`, wrapped only to record the props it is handed. Since `@objectstack/spec` 17.6.0 (objectstack-ai/objectstack#21215) the spec row is an array of CLOSED entries — `childObject` required, `columns` the spec\'s inline grid column, an undeclared member key refused by name — and `@object-ui/types` takes that row by reference, so both published faces give the spec\'s verdict on an entry (`packages/types/src/__tests__/object-master-detail-form-details-entry-11396.test.ts`). `MasterDetailDetailConfig` is DERIVED from that entry (objectui#11396) and held `Equal` to it at compile time in the pinned file, with ONE stated fork: the spec declares `sortField`, which this block does not read (row 2c), and the spec-side retirement is objectstack\'s. The registration declares `of: \'object\'`, derived from the now single-kind member contract. A declaration fixes an entry\'s SHAPE, not what this block DOES with a member, so the read site is still the whole member contract. New file (objectui#8071 slice 18).', + pins: 'Members are detail-collection OBJECTS (`MasterDetailDetailConfig`), and unlike this block\'s parent keys the read site is THIS block: `MasterDetailForm` is the key\'s only reader. Each member renders as its own section in AUTHORED order, headed by `title` (fallback `Line Items`), with `columns` reaching the line grid in authored order. ⭐ The sharp row: four members reach the grid through ONE hand-written object under the grid\'s camelCase keys (objectui#11610): `minRows`, `maxRows` and `addLabel` by the same name, and `amountField` RENAMED to the grid\'s `totalField` (the CHILD column summed; the detail\'s own `totalField`, the PARENT field, is not forwarded), beside `columns` and the DERIVED `sortField` (objectui#11070 round 9 retired the detail\'s `sortField` member; row 2c pins that a written one is read by nothing). That object is pinned as a SORTED KEY SET of exactly six, with two off-list members (one in the grid\'s own spelling, `allowAdd`) asserted not forwarded and an all-empty control beside it. `inlineMode: \'form\'` makes a list whose Add opens the full form (labelled `Add` when unset), and in grid mode `formFields` wider than `columns` offers the row form; each arm is the other\'s control. `childObject` and `relationshipField` address the atomic batch (each line a create on the child, linked by `{ $ref: 0 }`) and the edit-mode read (`{ $filter: { FK: recordId }, $top: 500 }` per collection). `totalField` receives the sum of the lines\' `amountField` on the PARENT leg, and a collection without one puts nothing there. With `{ childObject }` alone the FK and the columns are DERIVED from the child object, the one member behaviour the spec\'s description promises, and the batch writes the derived FK. `details: []` is the non-vacuity control: no section, no grid, a one-leg batch. The line grid is the REAL `LineItemsField`, wrapped only to record the props it is handed. Since `@objectstack/spec` 17.6.0 (objectstack-ai/objectstack#21215) the spec row is an array of CLOSED entries — `childObject` required, `columns` the spec\'s inline grid column, an undeclared member key refused by name — and `@object-ui/types` takes that row by reference, so both published faces give the spec\'s verdict on an entry (`packages/types/src/__tests__/object-master-detail-form-details-entry-11396.test.ts`). `MasterDetailDetailConfig` is DERIVED from that entry (objectui#11396) and held `Equal` to it at compile time in the pinned file, with ONE stated fork: 17.6.0 declares `sortField`, which this block does not read (row 2c); objectstack `main` has since retired it (objectstack-ai/objectstack#21589, unreleased after 17.6.0), and the fork closes at the bump to the first release carrying that. The registration declares `of: \'object\'`, derived from the now single-kind member contract. A declaration fixes an entry\'s SHAPE, not what this block DOES with a member, so the read site is still the whole member contract. New file (objectui#8071 slice 18).', }, 'object-master-detail-form.fields': { file: 'packages/plugin-form/src/__tests__/topLevelFieldsWarnCoverage-8847.test.tsx', diff --git a/packages/plugin-form/src/MasterDetailForm.tsx b/packages/plugin-form/src/MasterDetailForm.tsx index 2fa79a3713..647d58e1b8 100644 --- a/packages/plugin-form/src/MasterDetailForm.tsx +++ b/packages/plugin-form/src/MasterDetailForm.tsx @@ -78,16 +78,21 @@ import type { ObjectMasterDetailFormProps } from '@objectstack/spec/ui'; * `__tests__/masterDetailDetailsMembers-8071.test.tsx` holds the two equal at * compile time. * - * ⛔ ONE fork, stated rather than absorbed: the spec entry declares `sortField`, - * and this form reads no such member. objectui#11070 round 9 retired the - * authored override: the child field the grid stamps with each line's position - * is DERIVED from the child object (`deriveDetail` picks its `position` / - * `sort_order` / … field) and carried on the resolved entry, and row 2c of that - * test pins that a written `sortField` is read by nothing. The spec-side - * retirement is objectstack's half (recorded on - * objectstack-ai/objectstack#6017); until it lands, the member is left OFF this - * face, so the authoring face declares no key the renderer does not honour. - * That omission is the whole of what is hand-written about the shape. + * ⛔ ONE fork, stated rather than absorbed: at 17.6.0 the spec entry declares + * `sortField`, and this form reads no such member. objectui#11070 round 9 + * retired the authored override: the child field the grid stamps with each + * line's position is DERIVED from the child object (`deriveDetail` picks its + * `position` / `sort_order` / … field) and carried on the resolved entry, and + * row 2c of that test pins that a written `sortField` is read by nothing. + * objectstack has since retired the member on its `main` — a `retiredKey()` + * tombstone on the entry (objectstack-ai/objectstack#21589, landed by PR + * objectstack-ai/objectstack#21632 as `6ec54f00`), unreleased after 17.6.0 — + * so the fork closes at objectui's bump to the first `@objectstack/spec` + * release that carries it; the omission below then subtracts a key the entry + * no longer offers, and holds on both shapes. Until that bump the member is + * left OFF this face, so the authoring face declares no key the renderer does + * not honour. That omission is the whole of what is hand-written about the + * shape. * * Where each member lands — the renderer's reading, which the spec's * description does not carry: diff --git a/packages/plugin-form/src/__tests__/masterDetailDetailsMembers-8071.test.tsx b/packages/plugin-form/src/__tests__/masterDetailDetailsMembers-8071.test.tsx index ed817dd83c..087734855f 100644 --- a/packages/plugin-form/src/__tests__/masterDetailDetailsMembers-8071.test.tsx +++ b/packages/plugin-form/src/__tests__/masterDetailDetailsMembers-8071.test.tsx @@ -20,9 +20,12 @@ * objectui#11396): the spec row is an array of CLOSED entries — `childObject` * required, an undeclared key refused by name, `columns` the spec's inline grid * column — and `MasterDetailDetailConfig` is derived from that entry by - * reference, with ONE stated fork: the spec declares `sortField`, which this - * block does not read (objectui#11070 round 9 retired the authored override; - * row 2c below pins that a written one reaches nothing). The registration + * reference, with ONE stated fork: `@objectstack/spec` 17.6.0 declares + * `sortField`, which this block does not read (objectui#11070 round 9 retired + * the authored override; row 2c below pins that a written one reaches + * nothing); objectstack `main` has since retired it too + * (objectstack-ai/objectstack#21589, unreleased after 17.6.0), and the fork + * closes at objectui's bump to the first release carrying that. The registration * declares `of: 'object'`, derived from that single-kind member contract. The * compile-time block at the end of this file holds the config equal to the * spec's entry minus that fork and spells the member list out, so the member @@ -485,18 +488,19 @@ type SpecDetailEntry = NonNullable[numbe * The config IS the spec's entry minus the one stated fork, by reference * (objectui#11396): a member the spec adds, drops or retypes lands on the * config with no hand edit, and a hand-written restatement that drifts is red - * here before it is red anywhere else. + * here before it is red anywhere else. It holds on both spec shapes this + * repository is compiled against: 17.6.0, where the entry declares `sortField` + * as a string, and objectstack `main`, where the entry carries a `retiredKey()` + * tombstone for it (objectstack-ai/objectstack#21589, unreleased after 17.6.0) + * — the subtraction removes the key either way. ⛔ No row here pins which of + * the two shapes the spec has; the `Spec Main Shape Gate` compiles this file + * against `main`, so such a row is red there the day the two differ. The + * forward tripwire for the retirement is the runtime pin in `@object-ui/types` + * (`object-master-detail-form-details-entry-11396.test.ts`), which records + * `sortField` as ACCEPTED on 17.6.0 and flips at the bump. */ const derivedFromSpec: Equal> = true; -/** - * The fork is still open upstream: the spec entry declares `sortField`. When - * this row reds, objectstack retired the member (objectstack-ai/objectstack#6017 - * records that half), and the `Omit` in `MasterDetailForm.tsx` is to be dropped - * together with this row and the premise of row 2c and `retiredSortField`. - */ -const specStillDeclaresSortField: Equal = true; - /** A `columns` entry is the spec's inline grid column, the type the line grid reads. */ const columnsAreTheGridColumn: Equal[number], GridColumn> = true; @@ -522,6 +526,6 @@ const memberList: Equal< /** The lit control for the directive below: the same literal without the retired member compiles. */ const authoredDetail: MasterDetailDetailConfig = { childObject: 'po_step', amountField: 'qty' }; -// @ts-expect-error objectui#11070 round 9: `sortField` is retired as a detail member; the sort field is derived from the child object. The spec still declares it (`specStillDeclaresSortField` above), and this face leaves it off on purpose (objectui#11396). +// @ts-expect-error objectui#11070 round 9: `sortField` is retired as a detail member; the sort field is derived from the child object. This face leaves it off on purpose (objectui#11396), on 17.6.0 where the spec declares it and on objectstack main where the spec retired it too. const retiredSortField: MasterDetailDetailConfig = { childObject: 'po_step', sortField: 'line_no' }; -void [derivedFromSpec, specStillDeclaresSortField, columnsAreTheGridColumn, memberList, authoredDetail, retiredSortField]; +void [derivedFromSpec, columnsAreTheGridColumn, memberList, authoredDetail, retiredSortField]; diff --git a/packages/plugin-form/src/index.tsx b/packages/plugin-form/src/index.tsx index cb58d90ff5..fef9d7f94f 100644 --- a/packages/plugin-form/src/index.tsx +++ b/packages/plugin-form/src/index.tsx @@ -560,7 +560,7 @@ ComponentRegistry.register('object-master-detail-form', MasterDetailFormRenderer of: 'object', required: true, description: - 'The child collections, one OBJECT per entry, judged by the spec\'s closed `details` entry: `childObject` (required) names the child object; `relationshipField` is the FK on the child back to the parent, derived from the child\'s metadata when omitted; `columns` are the line grid\'s columns as the spec\'s inline grid column objects (`{ name, … }`, never a bare field name), derived from the child\'s fields when omitted; `formFields` names the per-row form\'s fields; `inlineMode` is `grid` (editable cells) or `form` (list + per-row form); `amountField` is the CHILD column summed and `totalField` the PARENT field that sum is saved to; `title`, `minRows`, `maxRows` and `addLabel` dress the section and its grid. An undeclared member key is refused by name. The entry\'s `sortField` is declared by the spec and read by nothing here: the line-position field is derived from the child object (objectui#11070).', + 'The child collections, one OBJECT per entry, judged by the spec\'s closed `details` entry: `childObject` (required) names the child object; `relationshipField` is the FK on the child back to the parent, derived from the child\'s metadata when omitted; `columns` are the line grid\'s columns as the spec\'s inline grid column objects (`{ name, … }`, never a bare field name), derived from the child\'s fields when omitted; `formFields` names the per-row form\'s fields; `inlineMode` is `grid` (editable cells) or `form` (list + per-row form); `amountField` is the CHILD column summed and `totalField` the PARENT field that sum is saved to; `title`, `minRows`, `maxRows` and `addLabel` dress the section and its grid. An undeclared member key is refused by name. The entry\'s `sortField` is read by nothing here — the line-position field is derived from the child object (objectui#11070) — and objectstack retires the key on its `main` (objectstack-ai/objectstack#21589, after 17.6.0).', }, { name: 'recordId', type: 'string', description: 'The parent record to load in `edit` mode. Leave unset for `create`.' }, // TWO values, not the six `object-form` declares (objectui#5939). A bare diff --git a/packages/types/src/__tests__/object-master-detail-form-details-entry-11396.test.ts b/packages/types/src/__tests__/object-master-detail-form-details-entry-11396.test.ts index 459cb64b37..9758ac91c5 100644 --- a/packages/types/src/__tests__/object-master-detail-form-details-entry-11396.test.ts +++ b/packages/types/src/__tests__/object-master-detail-form-details-entry-11396.test.ts @@ -38,14 +38,20 @@ * * ## The fork, pinned as ACCEPTED so that closing it is a deliberate change * - * The spec entry declares `sortField`; `MasterDetailForm` reads no such member - * (objectui#11070 round 9 retired the authored override, and + * At 17.6.0 the spec entry declares `sortField`; `MasterDetailForm` reads no + * such member (objectui#11070 round 9 retired the authored override, and * `plugin-form`'s `masterDetailDetailsMembers-8071.test.tsx` row 2c pins that a * written one reaches nothing). So `objectui validate` ACCEPTS an entry carrying - * `sortField` while the renderer ignores it. The spec-side retirement is - * objectstack's half (objectstack-ai/objectstack#6017); the row below records - * today's verdict, and reds the day the spec retires the key, which is the cue - * to flip it and drop the `Omit` on `MasterDetailDetailConfig`. + * `sortField` while the renderer ignores it. objectstack has since retired the + * key on its `main` with a `retiredKey()` tombstone on the entry + * (objectstack-ai/objectstack#21589, PR objectstack-ai/objectstack#21632, + * `6ec54f00`), unreleased after 17.6.0. The row below is the forward tripwire: + * it records the 17.6.0 verdict, and reds at objectui's bump to the first + * `@objectstack/spec` release that carries the retirement — the cue to flip it + * to a refusal and drop the `Omit` on `MasterDetailDetailConfig`. It is a + * RUNTIME row on purpose: the `Spec Main Shape Gate` compiles this repository + * against objectstack `main`, so a compile-time row pinning the 17.6.0 shape + * would be red there today. */ import { describe, it, expect } from 'vitest'; @@ -148,9 +154,10 @@ describe('an `object-master-detail-form` `details` entry is judged by the spec\' ]); }); - it('ACCEPTS `sortField` — the fork the spec still declares and the renderer does not read (objectstack\'s half)', () => { - // When this reds, the spec retired the key: flip this row and drop the - // `Omit` on `MasterDetailDetailConfig` in `@object-ui/plugin-form`. + it('ACCEPTS `sortField` on 17.6.0 — the forward tripwire for objectstack#21589, which retired the key on main after 17.6.0', () => { + // When this reds, the installed spec carries the retirement: flip this + // row to a refusal and drop the `Omit` on `MasterDetailDetailConfig` in + // `@object-ui/plugin-form`. const result = parse(nodeWith({ childObject: 'po_line', sortField: 'line_no' })); expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); });