From 793f55b65afa026edaf213cc9b636b597586bc79 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 17:11:09 +0000 Subject: [PATCH 1/7] =?UTF-8?q?feat(spec)!:=20addRecord=20says=20what=20re?= =?UTF-8?q?nders=20=E2=80=94=20enabled=20required,=20position=20top,=20mod?= =?UTF-8?q?e/formView=20retired=20(WIP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- content/docs/ui/create-vs-edit-form.mdx | 8 +- content/docs/ui/views.mdx | 2 +- .../src/ui/pages/active-projects.page.ts | 6 +- .../app-showcase/src/ui/views/contact.view.ts | 10 +- packages/lint/src/rule-explanations.ts | 2 +- packages/lint/src/showcase-shape.fixtures.ts | 2 +- .../lint/src/validate-list-view-field-refs.ts | 6 +- packages/spec/liveness/README.md | 2 +- packages/spec/liveness/view.json | 2 +- packages/spec/src/conversions/registry.ts | 293 +++++++++++++ .../18.ui__AddRecordConfig__formView.ts | 9 + .../18.ui__AddRecordConfig__mode.ts | 9 + .../18.add-record-enabled-required.ts | 29 ++ .../18.add-record-mode-form-view-retired.ts | 30 ++ .../spec/src/migrations/registry.ts.template | 26 ++ .../retired-key-removal-version.pin.test.ts | 11 +- .../ui/add-record-config-retirement.test.ts | 390 ++++++++++++++++++ packages/spec/src/ui/page.form.ts | 10 +- packages/spec/src/ui/page.test.ts | 2 - packages/spec/src/ui/view.form.ts | 12 +- packages/spec/src/ui/view.test.ts | 38 +- packages/spec/src/ui/view.zod.ts | 56 ++- 22 files changed, 896 insertions(+), 59 deletions(-) create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__AddRecordConfig__formView.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__AddRecordConfig__mode.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.add-record-enabled-required.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.add-record-mode-form-view-retired.ts create mode 100644 packages/spec/src/ui/add-record-config-retirement.test.ts diff --git a/content/docs/ui/create-vs-edit-form.mdx b/content/docs/ui/create-vs-edit-form.mdx index 09239c08cfb..f2f51d26872 100644 --- a/content/docs/ui/create-vs-edit-form.mdx +++ b/content/docs/ui/create-vs-edit-form.mdx @@ -56,8 +56,8 @@ export const ContactViews = defineView({ list: { type: 'grid', data, columns: [{ field: 'name' }, { field: 'email' }, { field: 'company' }, { field: 'stage' }], - // Bind the "+ Add record" entry point to the slim create form: - addRecord: { enabled: true, mode: 'form', formView: 'create' }, + // The "+ Add record" button opens the create form — `formViews.create` below: + addRecord: { enabled: true }, }, // Full edit form — one section per group DECLARED in the object's @@ -85,7 +85,7 @@ export const ContactViews = defineView({ }); ``` -The binding is `addRecord.mode: 'form'` + `addRecord.formView: 'create'` ([`AddRecordConfigSchema`](/docs/references/ui/view)). No `formViews.create` → the create entry derives a default. Present → it wins for create. +The create form is chosen by convention, and nothing on the list names it: every create entry — the list's Add button ([`AddRecordConfigSchema`](/docs/references/ui/view) says only whether it shows and where) and the object's New — opens `formViews.create` when the container declares it, else `form` (or `formViews.default`). No `formViews.create` → the create entry uses the default form. Present → it wins for create. ## Why @@ -114,7 +114,7 @@ Rule of thumb: **"different field subset" → derive. "different layout or flow" ## Runnable example - Object: [`examples/app-showcase/src/data/objects/contact.object.ts`](https://github.com/objectstack-ai/objectstack/blob/main/examples/app-showcase/src/data/objects/contact.object.ts) — flat, intent-tagged field set, with the four `fieldGroups` its `group` tags point at. -- Views: [`examples/app-showcase/src/ui/views/contact.view.ts`](https://github.com/objectstack-ai/objectstack/blob/main/examples/app-showcase/src/ui/views/contact.view.ts) — full edit form + sparse `formViews.create` + `addRecord` binding. +- Views: [`examples/app-showcase/src/ui/views/contact.view.ts`](https://github.com/objectstack-ai/objectstack/blob/main/examples/app-showcase/src/ui/views/contact.view.ts) — full edit form + sparse `formViews.create` + the list's Add button (`addRecord: { enabled: true }`), which opens it by convention. ## Anti-patterns diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx index 46c4596891b..9a41e957594 100644 --- a/content/docs/ui/views.mdx +++ b/content/docs/ui/views.mdx @@ -362,7 +362,7 @@ gantt: { A Form View defines how a single record is displayed for viewing or editing. It lives in the same `defineView` container as the object's list views: - `form` is the object's **default form** — the create and edit surfaces render it. -- `formViews` holds **additional named forms**. Each one is served only where it is asked for by name: a form action's `target` (`.`), `addRecord.formView`, or a public `sharing.publicLink`. A named form never becomes the default — a container with no `form` has no default form, even when it declares named ones. +- `formViews` holds **additional named forms**. Each one is served only where it is asked for by name: a form action's `target` (`.`) or a public `sharing.publicLink`. One key is read by convention: `formViews.create`, when declared, is the form every create entry opens (the list's Add button and the object's New). A named form never becomes the default — a container with no `form` has no default form, even when it declares named ones. ### Basic Structure diff --git a/examples/app-showcase/src/ui/pages/active-projects.page.ts b/examples/app-showcase/src/ui/pages/active-projects.page.ts index dea7a10f632..3dfd4372652 100644 --- a/examples/app-showcase/src/ui/pages/active-projects.page.ts +++ b/examples/app-showcase/src/ui/pages/active-projects.page.ts @@ -7,7 +7,7 @@ import { definePage } from '@objectstack/spec/ui'; * • `filterBy` — an ALWAYS-ON base filter (hide completed) the end user * cannot remove (distinct from userFilters, which they toggle). * • `sort` — a default sort order defined on the page (budget, high → low). - * • `addRecord` — a toolbar "add" entry point that opens a FORM. + * • `addRecord` — a toolbar "add" entry point that opens the create form. * • dropdown user-filters layered on top of the base filter. */ export const ActiveProjectsPage = definePage({ @@ -32,8 +32,8 @@ export const ActiveProjectsPage = definePage({ fields: [{ field: 'health', showCount: true }, { field: 'status' }], }, userActions: { sort: true, search: true, filter: true, rowHeight: false, addRecordForm: false }, - // Add-record entry point: a toolbar button that opens the default form. - addRecord: { enabled: true, position: 'top', mode: 'form', formView: 'default' }, + // Add-record entry point: a toolbar button that opens the create form. + addRecord: { enabled: true, position: 'top' }, showRecordCount: true, }, }); diff --git a/examples/app-showcase/src/ui/views/contact.view.ts b/examples/app-showcase/src/ui/views/contact.view.ts index 6bc1b5fb351..ac99eeccd1a 100644 --- a/examples/app-showcase/src/ui/views/contact.view.ts +++ b/examples/app-showcase/src/ui/views/contact.view.ts @@ -41,8 +41,9 @@ const data = { provider: 'object' as const, object: 'showcase_contact' }; * default — self-fills), `notes` (edit-time only). Nothing here restates a * field's type or rules; it only chooses WHICH fields appear and WHERE. * - * The list view binds the create entry point to that form via - * `addRecord: { mode: 'form', formView: 'create' }`. + * The list's Add button opens that form by convention: the create entry uses + * `formViews.create` when the container declares it, else the default `form`. + * Nothing on `addRecord` names it (`mode` / `formView` were retired, #24692). */ export const ContactViews = defineView({ // Default list — carries `data` so the view registrar can resolve the object. @@ -56,8 +57,9 @@ export const ContactViews = defineView({ { field: 'company' }, { field: 'stage' }, ], - // Create uses the slim, hand-shaped form rather than the full edit form. - addRecord: { enabled: true, mode: 'form', formView: 'create' }, + // The Add button opens the create form — `formViews.create` below, the + // slim, hand-shaped form, rather than the full edit form. + addRecord: { enabled: true }, }, // ── Default (edit/detail) form: the full record, grouped by `field.group`. diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 2d8e6cccc12..be55d5e7da6 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -3143,7 +3143,7 @@ const LIST_VIEW_FIELD_UNKNOWN_EXPLANATION: RuleExplanation = { 'external or introspected schema), a hop through a registry-injected system column, a list ' + 'view bound to a provider other than `object`, and the positions other rules own: `sort` ' + '(`sort-field-unknown`), `searchableFields` (`searchable-field-unknown`), the `chart` ' + - 'bindings, action names, `conditionalFormatting` predicates and `addRecord.formView`. An ' + + 'bindings, action names and `conditionalFormatting` predicates. An ' + 'unresolvable binding skips the whole list view on `os validate`, `os build` and `os lint`, so one ' + 'typo yields one finding; the runtime save door refuses it once instead, as `list-view-object-unknown` ' + 'or, for a write into a package, `list-view-object-outside-closure`.', diff --git a/packages/lint/src/showcase-shape.fixtures.ts b/packages/lint/src/showcase-shape.fixtures.ts index 04b92178f1a..6ba02c46214 100644 --- a/packages/lint/src/showcase-shape.fixtures.ts +++ b/packages/lint/src/showcase-shape.fixtures.ts @@ -411,7 +411,7 @@ export const SnapshotContactViews = defineView({ { field: 'company' }, { field: 'stage' }, ], - addRecord: { enabled: true, mode: 'form', formView: 'create' }, + addRecord: { enabled: true }, }, form: { diff --git a/packages/lint/src/validate-list-view-field-refs.ts b/packages/lint/src/validate-list-view-field-refs.ts index f1549557214..18368539a7c 100644 --- a/packages/lint/src/validate-list-view-field-refs.ts +++ b/packages/lint/src/validate-list-view-field-refs.ts @@ -202,10 +202,10 @@ * owned by `validateActionNameRefs`. * - **`conditionalFormatting[].condition`** — a CEL predicate, owned by the * expression rules. - * - **`addRecord.formView`** — a view name, owned by `lintViewRefs`. * (`pageName` was here too until #17063 retired the `type: 'page'` view - * mount, and `tabs[].view` until the list view's own `tabs` was retired; a - * list view carries neither reference now.) + * mount, `tabs[].view` until the list view's own `tabs` was retired, and + * `addRecord.formView` until #24692 retired it; a list view carries none of + * those references now.) * - **The `data.object` binding itself** — `validateObjectReferences` owns * object-name reference sites, with the curated cross-package severity * ladder a local "not in this stack ⇒ error" would not have. When the bound diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 98e850c1a32..1dd14dc1ce7 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -966,7 +966,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | skill | `permissions` REMOVED 2026-07 (#3704); `triggerPhrases` REMOVED 2026-07-30 (#3896 close-out sweep — phrases were never matched; activation is `triggerConditions` + the agent's `skills[]` + /skill-name pinning) | | dataset | `measures.certified` (declared-but-unenforced governance flag) REMOVED in 16.0 (#2377) | | page | live, plus the one dead tombstone below. The `print` declaration (#22158, 2026-10-08: the container, its eight keys and the four `margins` sides) was declared spec-first by card ① of the #8346 ruling (B′) — its REFUSALS live (the parse's `checkPagePrintComposition`, lint's `validatePrintPageBlocks`), its keys `planned` with `authorWarn` on the container — and flipped `live` 2026-10-16 (#23710) once card ②, the console's print rendering, reached the `.objectui-sha` pin: `PageRenderer` mounts objectui's `buildPagePrintStylesheet`, which reads every key. `authorWarn` went with the flip, so an author who writes `print` is no longer told the layout is not applied. Its earlier `planned` row, `requires`, flipped `live` 2026-10-02 (#20871): the save door refuses an html page whose written list disagrees with its source and stores the derived one, and boot hydration reports a stored page whose list names a plugin the deployment's SDUI manifest does not carry. Dead `assignedProfiles` REMOVED 2026-09-12 (ADR-0090 D2 + ADR-0049 — a per-page audience list named for the concept D2 deleted, with zero readers in either repo, so the page was open to everyone who could reach it). The row stays because `retiredKey` keeps the key in the walked shape (the `rls.priority` precedent). Its prior `live` verdict is the #12516 class twice over: the objectui bridge it cited never existed (lit control — two sibling objectui citations in the same file resolve), and the entry carried no `verifiedAt`, so nothing ever re-asked | -| view | list/form drilled via `children` (#2998 Track B); list.{responsive,performance} + form.{defaultSort,aria} REMOVED 2026-07-30 (#3896 close-out sweep — list aria/data stay live); **form.data was that sweep's one CORRECTION** — the removal attempt broke the build (`defineForm` writes `data.provider='schema'` onto every metadata form, `metadata-protocol` serves it), so it stands `live` with re-verified evidence; form.{buttons,defaults} live (framework#1894 / #2998); audit-era DEAD lines superseded by re-verification. **The dead set is six, not the four removals above**: #4534 (the last #4001 batch, batch 6e) declared three CONTAINER-level keys this row had never classified — `name` and `label`, both `dead`, and `object`, `live`. All three are properties of the `views: [...]` *container*, not of a view: `name` is dead because authoring it changes nothing — an authored value restates the key the container already registers under or contradicts it — and `label` is container display metadata with no reader. Neither is `authorWarn`'d and both are deliberately KEPT — the metadata door itself stamps the save name into every saved view body (`normalizeViewMetadata`) and its overlay paths key on that copy, so tombstoning `name` would reject the platform's own saves (re-measured 2026-10-02, #20301 stage 2: the earlier attribution to artifact-shipped containers and the metadata-validation sweep was the door's stamp misread; neither carries one). `object` is the container's object binding, and it was *stripped on every parse* until #4534 declared it. Separately, the level-2 dead residue (userActions.buttons, addRecord.mode/formView, tabs[].order) is noted on parents and is **not** in the counts — one drill level only **#9340**: `list.map` declared — the eighth visualization block (`ListMapConfigSchema`), keys mirroring objectui plugin-map's documented read set. FLIPPED `planned` → `live` 2026-08-24 (#11442): objectui#5908 landed `resolveListMapConfig`, which merges the view-level `map` block over the legacy `options.map` bag before `ListView.tsx`'s `case 'map'` forwards it into `ObjectMap`, with the same merged config also feeding the visualization-switcher's capability gate so a view binding coordinates only in the spec block is no longer filtered out of `allowedVisualizations` either (objectui#5042) **#22611** (2026-10-10): `list.requiredPermissions` is this type's only `planned` row (the `list.map` precedent above flipped `live`) — the audience gate (a list of capabilities, all required), declared spec-first under the ruling of record 6095014058, letter A, with `authorWarn` and an `authorHint` because no server reads it until the `/meta` read gate applies it (#22639, the carrier). The warning reaches a container's `list` only: the lint flattens one level of `children`, so a named `listViews` entry is not walked, and the key's describe carries the not-enforced clause for that door | +| view | list/form drilled via `children` (#2998 Track B); list.{responsive,performance} + form.{defaultSort,aria} REMOVED 2026-07-30 (#3896 close-out sweep — list aria/data stay live); **form.data was that sweep's one CORRECTION** — the removal attempt broke the build (`defineForm` writes `data.provider='schema'` onto every metadata form, `metadata-protocol` serves it), so it stands `live` with re-verified evidence; form.{buttons,defaults} live (framework#1894 / #2998); audit-era DEAD lines superseded by re-verification. **The dead set is six, not the four removals above**: #4534 (the last #4001 batch, batch 6e) declared three CONTAINER-level keys this row had never classified — `name` and `label`, both `dead`, and `object`, `live`. All three are properties of the `views: [...]` *container*, not of a view: `name` is dead because authoring it changes nothing — an authored value restates the key the container already registers under or contradicts it — and `label` is container display metadata with no reader. Neither is `authorWarn`'d and both are deliberately KEPT — the metadata door itself stamps the save name into every saved view body (`normalizeViewMetadata`) and its overlay paths key on that copy, so tombstoning `name` would reject the platform's own saves (re-measured 2026-10-02, #20301 stage 2: the earlier attribution to artifact-shipped containers and the metadata-validation sweep was the door's stamp misread; neither carries one). `object` is the container's object binding, and it was *stripped on every parse* until #4534 declared it. Separately, the level-2 dead residue (userActions.buttons, tabs[].order; addRecord.mode/formView until their #24692 removal) is noted on parents and is **not** in the counts — one drill level only **#9340**: `list.map` declared — the eighth visualization block (`ListMapConfigSchema`), keys mirroring objectui plugin-map's documented read set. FLIPPED `planned` → `live` 2026-08-24 (#11442): objectui#5908 landed `resolveListMapConfig`, which merges the view-level `map` block over the legacy `options.map` bag before `ListView.tsx`'s `case 'map'` forwards it into `ObjectMap`, with the same merged config also feeding the visualization-switcher's capability gate so a view binding coordinates only in the spec block is no longer filtered out of `allowedVisualizations` either (objectui#5042) **#22611** (2026-10-10): `list.requiredPermissions` is this type's only `planned` row (the `list.map` precedent above flipped `live`) — the audience gate (a list of capabilities, all required), declared spec-first under the ruling of record 6095014058, letter A, with `authorWarn` and an `authorHint` because no server reads it until the `/meta` read gate applies it (#22639, the carrier). The warning reaches a container's `list` only: the lint flattens one level of `children`, so a named `listViews` entry is not walked, and the key's describe carries the not-enforced clause for that door | | report | dataset-bound (ADR-0021); the aria/performance LEDGER entries were stale — the keys left the schema in the report-liveness close-out; deleted 2026-07-30 as hygiene. Audit-era `chart` DEAD superseded (framework#1890 / #3441) — live on non-joined reports only: a `joined` report's container `chart` is refused and `blocks[].chart` was removed (#20161, 2026-09-27; nothing drew either) | | dashboard | ADR-0021 dataset widgets (#3251; DashboardWidgetSchema `.strict()`); `aria`/`performance` (and widget `performance` + PerformanceConfigSchema) REMOVED 2026-07-30 (#3896 close-out sweep — no renderer applied any of them); audit-era `globalFilters`/`dateRange` DEAD superseded (framework#2501) **#4956**: `widgets` DRILLED — the row jumps 20 → 41 classified because all 22 widget-level keys enter the count at once. They had never been classified at all: the entry carried one blanket `live` plus a `note` asserting they were classified "in the DashboardWidgetSchema subtree", and no such subtree existed in any of the 28 ledger files. That gap, not any evidence, is what carried `widgets[].responsive` through the #3896 sweep that removed both its sibling `widgets[].performance` and its literal namesake `view.responsive` — `view` is drilled, so `list.responsive` got asked and went out. New dead 6 = `responsive` (retired #4876/#4995, tombstone keeps the row) + `colorVariant` + `actionUrl`/`actionType`/`actionIcon` + `aria`. The action trio is the sharpest: no renderer draws a per-widget action button at all (every `actionUrl` read in DashboardRenderer is scoped to `header.actions[]`), yet `validate-dashboard-action-refs.ts` enforces reference integrity on it and its docblock calls it "the per-widget button" — a lint guarding an affordance that does not exist. `requiresService` is the counter-example worth remembering: dead by every objectui measurement, and LIVE server-side (`filterDashboardForUser`, ADR-0057 D10) — judging a widget key from the renderer repo alone would have retired an enforced gate. `compareTo` is `live` on ONE path only (inline object-provider charts); on the ADR-0021 dataset path the string arms are dropped and `{ offset }` throws in the executor. **#6774** moves the row 33/8 → 34/7: `colorVariant` CORRECTED dead → live 2026-08-09, the enforce leg of #5010 ruling B landing from the renderer side (objectui#3359 / PR objectui#3799, absorbed by pin `09987b68`). Worth reading beside `requiresService` above, because it is the same lesson from the other end — that row warns against judging a widget key from the renderer repo alone, and this one is a `dead` verdict that was correct in this repo AND correct in the renderer repo on the day it was measured, and stopped being either when a cross-repo decision was implemented. A ledger row is a claim with a timestamp; `verifiedAt` is what makes the claim re-askable. It also empties the dashboard warn set, so the author-side lint now says nothing about any widget key — `dashboard` stays in the lint's TYPE_COLLECTIONS all the same (the `webhook`/`email_template` resolved state). **#17385** DRILLS `widgets.chartConfig` — 14 per-key verdicts where the row had carried one blanket `live`, re-measured against `.objectui-sha` pin `53ded82bf7a4`: 12 live (the nine chrome keys `chartConfigPresentation` lowers, plus `xAxis`/`yAxis`/`series`, whose PRESENTATION merges onto the derived bindings while `ChartAxis.field` and `ChartSeries.name` are dropped so membership stays with the dataset) and dead 2 — `type`, which parses and does nothing because the widget's own `type` owns the chart family, and `aria`, which has no reader on either face. Both are pinned as NEGATIVES in objectui, which is what makes them re-askable rather than merely asserted. ⚠️ The drill made SIX containers one level further down visible for the first time (`xAxis`/`yAxis`/`series`/`annotations`/`interaction`/`aria`, 39 child keys); they are RECORDED, not drilled — fanning this row's verdicts down over them would manufacture verdicts, and the evidence work is a separate measurement. Note the cell's previous last stated position (`34/7`) had already drifted one `dead` behind the generated artifact before this change; the counts columns are generated and are the authority **#22611** (2026-10-10): `requiredPermissions` is this type's only `planned` row — the audience gate (a list of capabilities, all required) shared word for word with the list view's, declared spec-first under the ruling of record 6095014058, letter A, with `authorWarn` and an `authorHint` because no server reads it until the `/meta` read gate applies it (#22639, the carrier) | | query | **not a metadata type** — the REQUEST surface (`QuerySchema`: client SDK QueryBuilder output; the `POST /data/:object/query` body), governed via `SPEC_ONLY_SCHEMAS` (#4286). The gate resolves 1 experimental at the depth this ledger drills; the 7 marker-experimental search affordances sit one level deeper, below what this ledger declares (the walk recurses since #17424, but only where a `children` map is written, and none is written here) — resolved from `[EXPERIMENTAL — not enforced]` describe markers, not ledger entries (search `fuzzy`/`operator`/`boost`/`minScore`/`language`/`highlight` + `aggregations[].filter` — declared engine affordances no executor receives). The #4286 sweep closed out same-release: `having` ENFORCED 2026-07-31 (engine-side post-aggregation filter, both paths; was finding 1); dead 4 = the tombstoned removals `joins`/`windowFunctions`/`cursor`/`distinct` — REMOVED 2026-07-31 (retiredKey keeps each in the walked shape so the rows stay; protocol-17 semantic migrations; the JoinNode + WindowFunctionNode clusters and the `QueryBuilder.cursor()`/`.distinct()` producers deleted with their keys; `distinct`'s mis-wired REST count suppression deleted too — finding 2). **#6815** adds the 5th dead: `aggregations[].distinct` REMOVED 2026-08-09 (live → dead, `-1` live). It is the one member of this ledger the #4286 sweep could not have caught with the question it asked — that sweep looked for keys NO executor reads, and this one had a reader: the objectql in-memory fallback deduplicated before applying the function while all five other faces (driver-sql, driver-turso, driver-mongodb, driver-memory, service-analytics' `AGGREGATE_SQL`) ignored it, so one query answered two plausible NUMBERS depending on which backend served it. The lesson for the next audit is the question, not the key: a per-key `live` verdict is only as good as the count of faces it was measured across, and this row's 2026-07-31 evidence (`in-memory-aggregation.ts:167,204-206`) was TRUE and still the wrong verdict. `count_distinct` is the surviving spelling (enforce leg, #6409) | diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index 26ab42e80e0..f2190cd3e76 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -227,7 +227,7 @@ }, "addRecord": { "status": "live", - "note": "objectui: ListView.tsx:374 (addRecord + userActions.addRecordForm drive the add-record flow). Sub-keys addRecord.mode / addRecord.formView have NO reader (audit L20, re-verified objectui@fb35e48) — dead sub-surface." + "note": "objectui: ListView.tsx:374 (addRecord + userActions.addRecordForm drive the add-record flow). Sub-keys addRecord.mode / addRecord.formView had NO reader (audit L20, re-verified objectui@fb35e48; re-read at objectui 616df6598b ListView.tsx#resolveAddRecordPlacement and the showAddRecord memo: the button needs an explicit `enabled: true`, an absent `position` renders at the top, nothing reads mode or formView). REMOVED 2026-10-20 (#24692, ruling letter B′) — tombstoned at the schema (retiredKey carries the prescription; authoring either is a tsc error and a parse error) and stripped from stored and built bodies by the protocol-18 conversion add-record-mode-form-view-removed. `enabled` is REQUIRED with no default and `position` defaults to `top`; add-record-enabled-defaulted writes the old default onto a stored block that omits `enabled`. The sub-keys are one drill level below this row (view/list.addRecord is a recorded undrilled container), so no row of their own exists to keep; the create form is chosen by convention (formViews.create when declared, else the default form)." }, "showRecordCount": { "status": "live", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index dc53cdff704..92255a1191a 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -13017,6 +13017,297 @@ const actionBlockEndpointToTarget: MetadataConversion = { }, }; +/** + * Every `addRecord` block a stack can carry, mapped immutably (#24692). + * + * `AddRecordConfigSchema` is reached from three doors, and this walks all three: + * + * - every list payload in `stack.views[]`, in all three persisted spellings + * ({@link mapViewPayloads}: the ViewItem record's `config`, a container's + * `list` / `listViews.*`, the flattened list overlay); + * - every object's `listViews.*` in `stack.objects[]` (`ObjectListViewSchema` + * carries the block too, and a stored object row is replayed as + * `{ objects: [row] }`); + * - every page's `interfaceConfig.addRecord` in `stack.pages[]` + * (`InterfacePageConfigSchema`, the list page's own configuration), and + * every page `list-view` node's `properties` ({@link mapPageComponents}, + * every position a component can sit in — its props row is built from + * `ListViewSchema`'s shape). + * + * `mapper` receives the block and the path of the KEY that holds it + * (`….addRecord`) and returns the same reference (no change) or a new dict; + * every container above it is copied only when the block changed. A non-dict + * `addRecord` is not this walker's: it passes through untouched, and the + * parse refuses it. + */ +function mapAddRecordBlocks(stack: Dict, mapper: (block: Dict, path: string) => Dict): Dict { + const inPayload = (payload: Dict, path: string): Dict => { + const block = payload.addRecord; + if (!isDict(block)) return payload; + const mapped = mapper(block, `${path}.addRecord`); + return mapped === block ? payload : { ...payload, addRecord: mapped }; + }; + + let next = mapViewPayloads(stack, (payload, kind, path) => (kind === 'list' ? inPayload(payload, path) : payload)); + + next = mapCollection(next, 'objects', (obj, path) => { + const listViews = obj.listViews; + if (!isDict(listViews)) return obj; + let changed = false; + const rebuilt: Dict = { ...listViews }; + for (const [name, entry] of Object.entries(listViews)) { + if (!isDict(entry)) continue; + const mapped = inPayload(entry, `${path}.listViews.${name}`); + if (mapped === entry) continue; + rebuilt[name] = mapped; + changed = true; + } + return changed ? { ...obj, listViews: rebuilt } : obj; + }); + + next = mapPages(next, (page, path) => { + const cfg = page.interfaceConfig; + if (!isDict(cfg)) return page; + const mapped = inPayload(cfg, `${path}.interfaceConfig`); + return mapped === cfg ? page : { ...page, interfaceConfig: mapped }; + }); + + return mapPageComponents(next, (component, path) => { + if (component.type !== 'list-view' || !isDict(component.properties)) return component; + const props = component.properties; + const mapped = inPayload(props, `${path}.properties`); + return mapped === props ? component : { ...component, properties: mapped }; + }); +} + +/** + * `addRecord` with `enabled` OMITTED → `enabled: true` (protocol 18, #24692 — + * ADR-0049 enforce-or-remove, ruling letter B′; the data-at-rest half of making + * `enabled` REQUIRED, the D3 semantic entry `add-record-enabled-required`). + * + * Ruling B′ made `AddRecordConfigSchema.enabled` required with no default, so + * `addRecord: {}` is refused at authoring and at publish instead of drawing + * nothing. Before it, the key carried `.default(true)`: the contract every + * author and every AI read said an `addRecord` block without `enabled` SHOWS + * the Add button, while the renderer drew it only for an explicit + * `enabled: true`. Under the standing 18.x rule for breaking narrowings (a + * narrowing an ADR-0087 conversion carries ships in an 18.x minor), this entry + * is what carries it: a stored row, a built artifact inside its declared-floor + * window and `os migrate meta --from 17` all get the old default written in, + * so no body that parsed under 18.0 is refused by this narrowing. + * + * **Lossless against the contract, NOT in pixels — stated, not hidden.** The + * value written is exactly the one the 18.0 parse produced for the same body + * (`.default(true)`), so the converted row means what the published spec said + * it meant. The renderer read the RAW stored body, though, so a row that + * omitted `enabled` drew no Add button and now draws one. That is the author's + * declared intent, and the measured population is zero: every in-tree + * producer (the showcase's two blocks, the lint fixture) and objectui's Studio + * design surface write `enabled` explicitly; production databases were not + * measured. + * + * **Deliberately narrow.** Only a dict `addRecord` with no `enabled` key is + * touched. An explicit value — `false`, `null`, a string — is the author's and + * is left for the parse to judge. `position` is never written: its default + * moved from `bottom` to `top`, where the renderer has always placed an absent + * position, so an omitted position keeps the placement it rendered with. + * + * **Retired from the load path, from day one.** The authoring funnel does not + * replay it, so a live author meets the parse's refusal and its prescription + * (`addRecord.enabled` is required). Idempotent by construction: a converted + * block carries `enabled`, which this transform never touches again. + */ +const addRecordEnabledDefaulted: MetadataConversion = { + id: 'add-record-enabled-defaulted', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '18.0.0', + surface: 'view.list.addRecord.enabled / object.listViews.*.addRecord.enabled / page.interfaceConfig.addRecord.enabled / page.component.list-view.addRecord.enabled — omitted', + summary: + "an `addRecord` block that omits `enabled` → `enabled: true` (ADR-0049 enforce-or-remove: `enabled` " + + 'is now required with no default; `true` is the default the 18.0 spec declared, so the block means ' + + 'what it always declared — note the renderer drew no button for such a block and now draws one; ' + + 'an explicit value is never touched)', + apply(stack, emit) { + return mapAddRecordBlocks(stack, (block, path) => { + if ('enabled' in block) return block; + emit({ from: '(omitted)', to: 'true', path: `${path}.enabled` }); + return { ...block, enabled: true }; + }); + }, + fixture: { + before: { + views: [ + // A container: the default `list` omits `enabled` and converts; the + // named entry declares it off and rides through untouched. + { + object: 'arx_ticket', + list: { type: 'grid', columns: ['subject'], addRecord: {} }, + listViews: { + closed: { type: 'grid', columns: ['subject'], addRecord: { enabled: false } }, + }, + }, + // A ViewItem record: the payload hangs off `config`; `position` survives. + { + name: 'arx_ticket.queue', + object: 'arx_ticket', + viewKind: 'list', + config: { type: 'grid', columns: ['subject'], addRecord: { position: 'bottom' } }, + }, + ], + objects: [ + { name: 'arx_ticket', label: 'Ticket', listViews: { mine: { type: 'grid', columns: ['subject'], addRecord: {} } } }, + ], + pages: [ + { name: 'arx_desk', label: 'Desk', type: 'list', interfaceConfig: { source: 'arx_ticket', addRecord: { position: 'top' } } }, + { + name: 'arx_home', + label: 'Home', + regions: [{ name: 'main', components: [{ type: 'list-view', properties: { addRecord: {} } }] }], + }, + ], + }, + after: { + views: [ + { + object: 'arx_ticket', + list: { type: 'grid', columns: ['subject'], addRecord: { enabled: true } }, + listViews: { + closed: { type: 'grid', columns: ['subject'], addRecord: { enabled: false } }, + }, + }, + { + name: 'arx_ticket.queue', + object: 'arx_ticket', + viewKind: 'list', + config: { type: 'grid', columns: ['subject'], addRecord: { position: 'bottom', enabled: true } }, + }, + ], + objects: [ + { name: 'arx_ticket', label: 'Ticket', listViews: { mine: { type: 'grid', columns: ['subject'], addRecord: { enabled: true } } } }, + ], + pages: [ + { name: 'arx_desk', label: 'Desk', type: 'list', interfaceConfig: { source: 'arx_ticket', addRecord: { position: 'top', enabled: true } } }, + { + name: 'arx_home', + label: 'Home', + regions: [{ name: 'main', components: [{ type: 'list-view', properties: { addRecord: { enabled: true } } }] }], + }, + ], + }, + // One notice per block filled: the container's `list`, the record's + // `config`, the object's `listViews.mine`, the page's `interfaceConfig`, + // the page `list-view` node's `properties`. + expectedNotices: 5, + }, +}; + +/** + * `addRecord.mode` / `addRecord.formView` leave the authorable surface + * (protocol 18, #24692 — ADR-0049 enforce-or-remove, ruling letter B′). + * + * Both keys parsed, were stored, and changed nothing: no renderer ever read + * either. objectui's list Add button opens the object's create form, the one + * the object list's New opens, whatever `mode` said (`inline`, its default, + * was never built), and it never opened the form `formView` named. The create + * form is chosen by convention instead — `formViews.create` when the object's + * view container declares one, else `form` / `formViews.default` — and how it + * opens stays the renderer's. + * + * **Retired from the load path** — both keys are `retiredKey()` tombstones on + * `AddRecordConfigSchema`, so a live author is refused at parse with the + * prescription. The entry exists because a stored row CAN carry them: every + * list-view door, the object door and the page door accepted and persisted + * them until this release, the Studio forms offered them, and the 18.0 parse + * materialized `mode: 'inline'` into every built `addRecord`. Without it such a + * row or artifact would carry a retired key back to a strict door. It also + * lets `os migrate meta --from 17` list the mechanical edits for existing + * sources. + * + * The delete is lossless in pixels: no renderer read either key, so removing + * them changes no screen. A `formView` that named the object's `create` form + * keeps opening it, by the convention above. `stripKeys` deletion is + * idempotent by construction. + */ +const addRecordModeFormViewRemoved: MetadataConversion = { + id: 'add-record-mode-form-view-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '18.0.0', + surface: 'view.list.addRecord.mode / view.list.addRecord.formView / object.listViews.*.addRecord.mode / object.listViews.*.addRecord.formView / page.interfaceConfig.addRecord.mode / page.interfaceConfig.addRecord.formView / page.component.list-view.addRecord.mode / page.component.list-view.addRecord.formView', + summary: + "add-record keys 'mode'/'formView' removed (ADR-0049 enforce-or-remove — parsed and stored, read by " + + 'no renderer: the Add button always opens the create form, chosen by convention as ' + + '`formViews.create` when declared, else the default form)', + apply(stack, emit) { + return mapAddRecordBlocks(stack, (block, path) => stripKeys(block, ['mode', 'formView'], emit, path)); + }, + fixture: { + before: { + views: [ + // A container: the default `list` carries both keys, the named entry + // only `mode`, the other named entry neither and rides through. + { + object: 'arx_contact', + list: { type: 'grid', columns: ['name'], addRecord: { enabled: true, mode: 'form', formView: 'create' } }, + listViews: { + recent: { type: 'grid', columns: ['name'], addRecord: { enabled: true, position: 'both', mode: 'inline' } }, + all: { type: 'grid', columns: ['name'], addRecord: { enabled: false } }, + }, + }, + ], + objects: [ + { name: 'arx_contact', label: 'Contact', listViews: { mine: { type: 'grid', columns: ['name'], addRecord: { enabled: true, formView: 'default' } } } }, + ], + pages: [ + { + name: 'arx_contacts', + label: 'Contacts', + type: 'list', + interfaceConfig: { source: 'arx_contact', addRecord: { enabled: true, position: 'top', mode: 'modal', formView: 'default' } }, + }, + { + name: 'arx_people', + label: 'People', + regions: [{ name: 'main', components: [{ type: 'list-view', properties: { addRecord: { enabled: true, mode: 'form' } } }] }], + }, + ], + }, + after: { + views: [ + { + object: 'arx_contact', + list: { type: 'grid', columns: ['name'], addRecord: { enabled: true } }, + listViews: { + recent: { type: 'grid', columns: ['name'], addRecord: { enabled: true, position: 'both' } }, + all: { type: 'grid', columns: ['name'], addRecord: { enabled: false } }, + }, + }, + ], + objects: [ + { name: 'arx_contact', label: 'Contact', listViews: { mine: { type: 'grid', columns: ['name'], addRecord: { enabled: true } } } }, + ], + pages: [ + { + name: 'arx_contacts', + label: 'Contacts', + type: 'list', + interfaceConfig: { source: 'arx_contact', addRecord: { enabled: true, position: 'top' } }, + }, + { + name: 'arx_people', + label: 'People', + regions: [{ name: 'main', components: [{ type: 'list-view', properties: { addRecord: { enabled: true } } }] }], + }, + ], + }, + // One notice per key removed: `list` (2), `listViews.recent` (1), the + // object's `listViews.mine` (1), the page's `interfaceConfig` (2), the + // page `list-view` node's `properties` (1). + expectedNotices: 7, + }, +}; + /** * An agent's conversation state machine — `agent.lifecycle` — leaves the spec * (protocol 18, #21320; ADR-0049 enforce-or-remove, ruled D (retire) on @@ -17641,6 +17932,8 @@ function inApplicationOrder(entries: readonly OrderedConversion[]): readonly Met const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: actionAriaRemoved, order: 43 }, { conversion: actionBlockEndpointToTarget, order: 52 }, + { conversion: addRecordEnabledDefaulted, order: 73 }, + { conversion: addRecordModeFormViewRemoved, order: 72 }, { conversion: agentLifecycleRemoved, order: 57 }, { conversion: agentMemoryLongTermStoreRemoved, order: 56 }, { conversion: agentStructuredOutputRefusedMembersRemoved, order: 55 }, diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__AddRecordConfig__formView.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__AddRecordConfig__formView.ts new file mode 100644 index 00000000000..b21e806b050 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__AddRecordConfig__formView.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #24692 (ADR-0049 enforce-or-remove; ruling letter B′). `addRecord.formView` +// named a form view for the Add button to open, and no renderer read it: the +// create form is chosen by convention — `formViews.create` when the object's +// view container declares one, else the default form. Tombstoned with +// `retiredKey()` on `AddRecordConfigSchema` beside `mode`. D2: +// `add-record-mode-form-view-removed`. +export const entry = 'ui/AddRecordConfig:formView'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__AddRecordConfig__mode.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__AddRecordConfig__mode.ts new file mode 100644 index 00000000000..8773231a077 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__AddRecordConfig__mode.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #24692 (ADR-0049 enforce-or-remove; ruling letter B′). `addRecord.mode` +// (`inline` / `form` / `modal`, default `inline`) was read by no renderer: the +// list's Add button always opens the object's create form. Tombstoned with +// `retiredKey()` on `AddRecordConfigSchema`, the one def every list-view door, +// the object's `listViews`, the page's `interfaceConfig` and a page +// `list-view` node share. D2: `add-record-mode-form-view-removed`. +export const entry = 'ui/AddRecordConfig:mode'; diff --git a/packages/spec/src/migrations/entries/semantic/18.add-record-enabled-required.ts b/packages/spec/src/migrations/entries/semantic/18.add-record-enabled-required.ts new file mode 100644 index 00000000000..a220df270a0 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.add-record-enabled-required.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #24692 (ADR-0049 enforce-or-remove; ruling letter B′) — the D3 entry of the +// `add-record-enabled-defaulted` conversion: `addRecord.enabled` becomes +// REQUIRED with no default, and `position` defaults to `top`. The conversion +// writes the old default (`true`) onto a stored block that omits `enabled`; +// what it cannot know is whether the author wanted the button the old default +// promised, since the renderer never drew one for such a block. +export const entry: SemanticMigration = { + id: 'add-record-enabled-required', + surface: 'view.list.addRecord.enabled — omitted (was `.default(true)`); view.list.addRecord.position — default `bottom` → `top`', + replacement: 'Write `enabled` on every `addRecord` block: `enabled: true` shows the Add button, ' + + '`enabled: false` declares it off. `position` defaults to `top` (the toolbar); write ' + + '`position: \'bottom\'` or `\'both\'` to place it elsewhere.', + reason: 'The 18.0 spec declared `enabled` `.default(true)` and `position` `.default(\'bottom\')`, ' + + 'while the renderer drew the Add button only for an explicit `enabled: true` and placed an ' + + 'absent position at the top. So `addRecord: {}` promised a button at the bottom and drew ' + + 'nothing. The spec now says what renders. The D2 conversion `add-record-enabled-defaulted` ' + + 'writes `enabled: true`, the declared default, onto a stored or built block that omits it, so ' + + 'such a block is not refused; the price is that the block now draws the button it always ' + + 'declared. A block meant to draw nothing must say `enabled: false`. `position` is not ' + + 'converted: an omitted position renders at the top before and after.', + acceptanceCriteria: 'Every `addRecord` block declares `enabled`; the parse refuses a block ' + + 'without it with a prescription naming the key. A list that should show the Add button ' + + 'shows it where `position` says (the toolbar when omitted); a list that should not declares ' + + '`enabled: false` or drops the block.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.add-record-mode-form-view-retired.ts b/packages/spec/src/migrations/entries/semantic/18.add-record-mode-form-view-retired.ts new file mode 100644 index 00000000000..4b5c1053315 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.add-record-mode-form-view-retired.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #24692 (ADR-0049 enforce-or-remove; ruling letter B′) — the D3 entry of the +// `add-record-mode-form-view-removed` family. Registered keys: +// `ui/AddRecordConfig:mode` and `ui/AddRecordConfig:formView`. The strip is +// lossless in pixels (no renderer read either key); what the conversion cannot +// do is give a list its own create form, which is a `formViews.create` +// declaration the author writes. +export const entry: SemanticMigration = { + id: 'add-record-mode-form-view-retired', + surface: 'view.list.addRecord.mode / view.list.addRecord.formView — the list Add button\'s open mode and named form', + replacement: 'Nothing replaces `mode`: the Add button opens the object\'s create form the way the ' + + 'object list\'s New opens it. In place of `formView`, the create form is chosen by convention: ' + + 'declare `formViews.create` on the object\'s view container to give creating its own form; ' + + 'without one, `form` (or `formViews.default`) is used.', + reason: 'The D2 conversion `add-record-mode-form-view-removed` deletes both keys from every ' + + '`addRecord` block it reaches — every list payload in `stack.views[]`, every object\'s ' + + '`listViews`, every page\'s `interfaceConfig` and every page `list-view` node. The delete ' + + 'changes no screen, because no renderer ever read either key. What it cannot decide is ' + + 'whether a `formView` that named some form other than the object\'s create form expressed a ' + + 'real need: if the author wanted adding a record to open a particular form, that form ' + + 'becomes the object\'s `formViews.create`, which then applies to every create entry of the ' + + 'object, not to one list.', + acceptanceCriteria: 'No `addRecord` block declares `mode` or `formView`; the parse refuses ' + + 'either key by name. For each block whose `formView` named a form other than the default: ' + + 'either the object\'s view container declares that form as `formViews.create`, or the author ' + + 'decided the default form is right. The Add button opens the create form in every case.', +}; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index f3d51f4bd37..ceab8253a6c 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -1240,6 +1240,32 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'one is left as stored and reported as a TODO. Its D3 record is the semantic entry ' + '`action-block-endpoint-spelling-retired`.', }, + { + id: 'add-record-enabled-required', + order: 106, + text: + 'It also makes a list view\'s `addRecord` block say what renders (ADR-0049 enforce-or-remove; ' + + 'ruling letter B′). The block declared `enabled` `.default(true)` and `position` ' + + '`.default(\'bottom\')`, while the renderer drew the Add button only for an explicit ' + + '`enabled: true` and placed an absent position at the top, so `addRecord: {}` promised a ' + + 'button and drew nothing. `enabled` is now required with no default and `position` defaults ' + + 'to `top`. The D2 conversion `add-record-enabled-defaulted` writes the declared default, ' + + '`enabled: true`, onto a stored or built block that omits it, so no such block is refused, ' + + 'and is retired from the load path, so a live author is refused at parse with the ' + + 'prescription. The D3 record is the semantic entry `add-record-enabled-required`.', + }, + { + id: 'add-record-mode-form-view-retired', + order: 106, + text: + 'It also retires `addRecord.mode` and `addRecord.formView` (ADR-0049 enforce-or-remove; ' + + 'ruling letter B′): no renderer read either key, and the Add button always opens the ' + + 'object\'s create form, chosen by convention as `formViews.create` when the object\'s view ' + + 'container declares one, else the default form. The D2 conversion ' + + '`add-record-mode-form-view-removed` strips both keys from every `addRecord` block as a ' + + 'lossless delete and is retired from the load path. The D3 record is the semantic entry ' + + '`add-record-mode-form-view-retired`.', + }, { id: 'admin-export-wildcard-removed', order: 4, diff --git a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts index 29c18a41e43..e6d087a409a 100644 --- a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts +++ b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts @@ -90,7 +90,7 @@ import { AdminScopeSchema, PermissionSetSchema } from '../security/permission.zo import { SharingLevel } from '../security/sharing.zod'; import { DatasourceSchema } from '../data/datasource.zod'; import { PageComponentSchema, PageSchema } from '../ui/page.zod'; -import { FormFieldSchema, FormSectionSchema, ListViewSchema, ObjectListViewSchema } from '../ui/view.zod'; +import { AddRecordConfigSchema, FormFieldSchema, FormSectionSchema, ListViewSchema, ObjectListViewSchema } from '../ui/view.zod'; import { FlowSchema } from '../automation/flow.zod'; import { JobSchema } from '../system/job.zod'; import { PROTOCOL_MAJOR } from '../kernel/protocol-version'; @@ -346,6 +346,15 @@ const JOIN: Readonly> = { 'page-header-show-star-removed': [tombstone(PageHeaderProps, 'page:header', 'showStar')], // #24640: retired after 17.7.0, so the v18 train's major. One tombstone on // the list-view shape, walked as both list-view defs. + // #24692: retired after 18.0.0, the first post-GA 18.x retirement — so the + // newest published stable release, and the v18 train's major. + 'add-record-mode-form-view-removed': [ + tombstone(AddRecordConfigSchema, 'addRecord', 'mode'), + tombstone(AddRecordConfigSchema, 'addRecord', 'formView'), + ], + 'add-record-enabled-defaulted': [ + refusal('an addRecord block without `enabled`', () => AddRecordConfigSchema.safeParse({}), 'enabled'), + ], 'view-list-filterable-fields-removed': [ tombstone(ListViewSchema, 'view.list', 'filterableFields'), tombstone(ObjectListViewSchema, 'object.listViews.*', 'filterableFields'), diff --git a/packages/spec/src/ui/add-record-config-retirement.test.ts b/packages/spec/src/ui/add-record-config-retirement.test.ts new file mode 100644 index 00000000000..1ee59044bce --- /dev/null +++ b/packages/spec/src/ui/add-record-config-retirement.test.ts @@ -0,0 +1,390 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A list's `addRecord` block now says what renders (#24692) — ADR-0049 + * enforce-or-remove, ruling letter B′, shipped in 18.x under the standing rule + * for breaking narrowings (a narrowing an ADR-0087 conversion carries ships in + * an 18.x minor). + * + * Measured before the change, at objectui's `ListView`: the Add button is drawn + * only for an explicit `enabled: true`, an absent `position` is placed at the + * top, and nothing reads `mode` or `formView` — every Add opens the create + * form. The spec declared `enabled` `.default(true)`, `position` + * `.default('bottom')`, `mode` `.default('inline')` and an optional `formView`. + * + * Bookkeeping shapes, pinned below: + * 1. `enabled` REQUIRED with no default, refused with a prescription when + * omitted; `position` defaults to `top`. `AddRecordConfigSchema` is the one + * def behind every door: a list view (`ListViewSchema`), an object's or a + * container's `listViews` (`ObjectListViewSchema`), a list page's + * `interfaceConfig` and a page `list-view` node's `properties`. + * 2. `mode` / `formView` are `retiredKey()` tombstones on that def. + * 3. Two D2 conversions, both retired from the load path: + * `add-record-mode-form-view-removed` strips the two keys, and + * `add-record-enabled-defaulted` writes the old default `enabled: true` + * onto a block that omits it — so a stored row or a built artifact is + * converted, never refused, while a live author is taught at parse. + * 4. `RETIRED_KEYS_BY_MAJOR[18]` carries the two keys; each conversion has a + * D3 entry. + * 5. The Studio forms offer exactly `enabled` and `position`. + * 6. Tree-scoped absence: no `addRecord` block inside the radius still + * authors `mode` or `formView`. + * + * On the assertion set: a schema refusal raises a `ZodError` whose issues carry + * `code` and `path` but no ADR-0112 `status` (that envelope belongs to the API + * error surface), so each refusal is pinned by the issue `code`, the `path` + * naming the key, and the prescription. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { listRepoWalkFiles } from '../../scripts/lib/repo-walk-testkit'; +import { collectConversionNotices } from '../conversions/apply'; +import { ALL_CONVERSIONS } from '../conversions/registry'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import type { ConversionNotice } from '../conversions/types'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { ComponentPropsMap } from './component.zod'; +import { pageForm } from './page.form'; +import { InterfacePageConfigSchema } from './page.zod'; +import { viewForm } from './view.form'; +import { AddRecordConfigSchema, ListViewSchema, ObjectListViewSchema } from './view.zod'; + +const STRIP_ID = 'add-record-mode-form-view-removed'; +const FILL_ID = 'add-record-enabled-defaulted'; + +// Unanchored, because a thrown `ZodError`'s message is the JSON of its issues; +// the key-first house convention is asserted on the issue message itself. +const MODE_PRESCRIPTION = + /`addRecord\.mode` was removed in @objectstack\/spec 18\.1\.0 \(ADR-0049 enforce-or-remove\) — no renderer ever read it.*Delete the key.*Run `os migrate meta --from 17`/s; +const FORM_VIEW_PRESCRIPTION = + /`addRecord\.formView` was removed in @objectstack\/spec 18\.1\.0 \(ADR-0049 enforce-or-remove\) — no renderer ever read it.*Delete the key, and to give the Add button its own form declare it as `formViews\.create`.*Run `os migrate meta --from 17`/s; +const ENABLED_PRESCRIPTION = + /`addRecord\.enabled` is required since @objectstack\/spec 18\.1\.0.*Write `enabled: true` to show the Add button.*`enabled: false`.*Run `os migrate meta --from 17`/s; + +type Parse = { success: boolean; error?: { issues: { code: string; path: PropertyKey[]; message: string }[] } }; +type Door = { name: string; parse: (addRecord: unknown) => Parse; at: PropertyKey[] }; + +const LIST = { type: 'grid', columns: ['name'] } as const; + +/** Every door the block reaches, with the issue path the block sits at. */ +const DOORS: readonly Door[] = [ + { name: 'AddRecordConfigSchema', parse: (b) => AddRecordConfigSchema.safeParse(b), at: [] }, + { name: 'view.list', parse: (b) => ListViewSchema.safeParse({ ...LIST, addRecord: b }), at: ['addRecord'] }, + { name: 'object.listViews.*', parse: (b) => ObjectListViewSchema.safeParse({ ...LIST, addRecord: b }), at: ['addRecord'] }, + { name: 'page.interfaceConfig', parse: (b) => InterfacePageConfigSchema.safeParse({ source: 'crm_contact', addRecord: b }), at: ['addRecord'] }, + { name: 'page list-view node', parse: (b) => ComponentPropsMap['list-view'].safeParse({ addRecord: b }), at: ['addRecord'] }, +]; + +describe('addRecord — `enabled` is required, `position` defaults to `top`', () => { + it.each(DOORS.map((d) => [d.name, d] as const))('%s refuses a block that omits `enabled`, with the prescription', (_, door) => { + for (const block of [{}, { position: 'bottom' }]) { + const r = door.parse(block); + expect(r.success, JSON.stringify(block)).toBe(false); + const issues = r.error!.issues; + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('invalid_type'); + expect(issues[0]!.path).toEqual([...door.at, 'enabled']); + expect(issues[0]!.message).toMatch(ENABLED_PRESCRIPTION); + expect(issues[0]!.message.startsWith('`addRecord.enabled` is required')).toBe(true); + } + }); + + it('a non-boolean `enabled` is refused by the type, not by the omission prescription', () => { + const r = AddRecordConfigSchema.safeParse({ enabled: 'yes' }); + expect(r.success).toBe(false); + expect(r.error!.issues[0]!.path).toEqual(['enabled']); + expect(r.error!.issues[0]!.message).not.toMatch(ENABLED_PRESCRIPTION); + }); + + it.each(DOORS.map((d) => [d.name, d] as const))('%s accepts `enabled: true` / `false` and defaults `position` to `top`', (_, door) => { + for (const enabled of [true, false]) { + expect(door.parse({ enabled }).success, `enabled: ${enabled}`).toBe(true); + } + expect(AddRecordConfigSchema.parse({ enabled: true })).toEqual({ enabled: true, position: 'top' }); + expect(AddRecordConfigSchema.parse({ enabled: true, position: 'both' })).toEqual({ enabled: true, position: 'both' }); + }); + + it('fails tsc at the authoring site when `enabled` is omitted', () => { + // @ts-expect-error — `enabled` is required on the input type. + const block: z.input = { position: 'top' }; + expect(AddRecordConfigSchema.safeParse(block).success).toBe(false); + }); +}); + +describe('addRecord — `mode` / `formView` tombstones', () => { + const CASES = [ + ['mode', 'form', MODE_PRESCRIPTION], + ['mode', 'inline', MODE_PRESCRIPTION], + ['formView', 'create', FORM_VIEW_PRESCRIPTION], + ] as const; + + it.each(DOORS.map((d) => [d.name, d] as const))('%s refuses each key by the tombstone, at the key, with the prescription', (_, door) => { + for (const [key, value, prescription] of CASES) { + const r = door.parse({ enabled: true, [key]: value }); + expect(r.success, `${key}: ${value}`).toBe(false); + const issues = r.error!.issues; + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('invalid_type'); + expect(issues[0]!.code, 'the tombstone answers, not the strict unknown-key arm').not.toBe('unrecognized_keys'); + expect(issues[0]!.path).toEqual([...door.at, key]); + expect(issues[0]!.message).toMatch(prescription); + expect(issues[0]!.message.startsWith(`\`addRecord.${key}\` was removed`)).toBe(true); + } + }); + + it('CONTROL: an undeclared sibling gets the strict unknown-key answer, not a tombstone', () => { + const r = AddRecordConfigSchema.safeParse({ enabled: true, openIn: 'modal' }); + expect(r.success).toBe(false); + expect(r.error!.issues.map((i) => i.code)).toEqual(['unrecognized_keys']); + }); + + it('the walked shape keeps both keys — their authorable-surface rows stay reachable', () => { + const shape = (AddRecordConfigSchema as unknown as { shape: Record }).shape; + expect(Object.keys(shape).sort()).toEqual(['enabled', 'formView', 'mode', 'position']); + expect(shape.mode!.description?.startsWith('[REMOVED] ')).toBe(true); + expect(shape.formView!.description?.startsWith('[REMOVED] ')).toBe(true); + }); + + it('fails tsc at the authoring site: the input type of each key is `never`', () => { + const block: z.input = { + enabled: true, + // @ts-expect-error — `mode` is a retiredKey() tombstone: its input type is `never`. + mode: 'form', + // @ts-expect-error — `formView` is a retiredKey() tombstone: its input type is `never`. + formView: 'create', + }; + expect(() => AddRecordConfigSchema.parse(block)).toThrow(MODE_PRESCRIPTION); + }); +}); + +type Notice = Pick; +const brief = (n: ConversionNotice): Notice => ({ conversionId: n.conversionId, path: n.path, from: n.from, to: n.to }); +const ours = (n: ConversionNotice) => n.conversionId === STRIP_ID || n.conversionId === FILL_ID; + +describe('addRecord — the D2 conversions', () => { + it('a STORED view container in the 18.0 shape replays to a body the strict door accepts', () => { + const row = { + object: 'crm_contact', + list: { ...LIST, addRecord: { enabled: true, mode: 'form', formView: 'create' } }, + listViews: { recent: { ...LIST, addRecord: {} } }, + }; + const notices: ConversionNotice[] = []; + const out = applyConversionsToStoredItem('view', row, { onNotice: (n) => notices.push(n) }) as typeof row; + expect(notices.filter(ours).map(brief)).toEqual([ + { conversionId: STRIP_ID, path: 'views[0].list.addRecord.mode', from: 'mode', to: '(removed)' }, + { conversionId: STRIP_ID, path: 'views[0].list.addRecord.formView', from: 'formView', to: '(removed)' }, + { conversionId: FILL_ID, path: 'views[0].listViews.recent.addRecord.enabled', from: '(omitted)', to: 'true' }, + ]); + expect(out.list.addRecord).toEqual({ enabled: true }); + expect(out.listViews.recent.addRecord).toEqual({ enabled: true }); + expect(ListViewSchema.safeParse(out.list).success).toBe(true); + expect(ObjectListViewSchema.safeParse(out.listViews.recent).success).toBe(true); + // Copy-on-write: the stored row is not mutated. + expect(row.list.addRecord).toHaveProperty('mode'); + expect(row.listViews.recent.addRecord).not.toHaveProperty('enabled'); + }); + + it('a STORED page reaches both page doors: `interfaceConfig` and a `list-view` node', () => { + const row = { + name: 'contacts', + label: 'Contacts', + type: 'list', + interfaceConfig: { source: 'crm_contact', addRecord: { position: 'top', mode: 'modal' } }, + regions: [{ name: 'main', components: [{ type: 'list-view', properties: { addRecord: { enabled: false, formView: 'x' } } }] }], + }; + const notices: ConversionNotice[] = []; + const out = applyConversionsToStoredItem('page', row, { onNotice: (n) => notices.push(n) }) as typeof row; + expect(notices.filter(ours).map((n) => n.path).sort()).toEqual([ + 'pages[0].interfaceConfig.addRecord.enabled', + 'pages[0].interfaceConfig.addRecord.mode', + 'pages[0].regions[0].components[0].properties.addRecord.formView', + ]); + expect(out.interfaceConfig.addRecord).toEqual({ position: 'top', enabled: true }); + // An explicit `enabled: false` is the author's and is never overwritten. + expect(out.regions[0]!.components[0]!.properties.addRecord).toEqual({ enabled: false }); + expect(InterfacePageConfigSchema.safeParse(out.interfaceConfig).success).toBe(true); + }); + + it('a STORED object reaches its own `listViews`', () => { + const row = { name: 'crm_contact', label: 'Contact', listViews: { mine: { ...LIST, addRecord: { mode: 'inline' } } } }; + const notices: ConversionNotice[] = []; + const out = applyConversionsToStoredItem('object', row, { onNotice: (n) => notices.push(n) }) as typeof row; + expect(notices.filter(ours).map((n) => n.path)).toEqual([ + 'objects[0].listViews.mine.addRecord.mode', + 'objects[0].listViews.mine.addRecord.enabled', + ]); + expect(out.listViews.mine.addRecord).toEqual({ enabled: true }); + }); + + it('leaves a non-dict `addRecord` and an explicit `enabled` value to the parse', () => { + const stack = { views: [{ object: 'o', list: { ...LIST, addRecord: true } }, { object: 'p', list: { ...LIST, addRecord: { enabled: null } } }] }; + const { stack: out, notices } = collectConversionNotices(stack, { includeRetired: true }); + expect(notices.filter(ours)).toEqual([]); + expect(out).toBe(stack); + }); + + it('is idempotent by construction: a second replay converts nothing', () => { + const { stack } = collectConversionNotices( + { views: [{ object: 'o', list: { ...LIST, addRecord: { mode: 'form', formView: 'create' } } }] }, + { includeRetired: true }, + ); + const replay = collectConversionNotices(stack, { includeRetired: true }); + expect(replay.notices.filter(ours)).toEqual([]); + expect(replay.stack).toBe(stack); + }); + + it('both are retired from the load path — the authoring funnel rewrites no live source', () => { + const input = { views: [{ object: 'o', list: { ...LIST, addRecord: { mode: 'form' } } }] }; + const { stack, notices } = collectConversionNotices(input); + expect(notices.filter(ours)).toEqual([]); + expect(stack).toEqual(input); + }); +}); + +describe('addRecord — the ADR-0087 ledger rows', () => { + it('declares both keys on the shared def under major 18', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toEqual(expect.arrayContaining(['ui/AddRecordConfig:mode', 'ui/AddRecordConfig:formView'])); + }); + + it.each([STRIP_ID, FILL_ID])('wires %s into the step-18 chain as a retired, stamped entry', (id) => { + expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain(id); + const conversion = ALL_CONVERSIONS.find((c) => c.id === id); + expect(conversion).toBeDefined(); + expect(conversion!.toMajor).toBe(18); + expect(conversion!.retiredFromLoadPath).toBe(true); + expect(conversion!.retiredAfter).toBe('18.0.0'); + }); + + it.each([ + ['add-record-mode-form-view-retired', STRIP_ID], + ['add-record-enabled-required', FILL_ID], + ])('carries the D3 entry %s, naming its D2 conversion', (d3, d2) => { + const entries = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((s) => s.id === d3); + expect(entries).toHaveLength(1); + expect(entries[0]!.reason).toContain(`\`${d2}\``); + expect(entries[0]!.acceptanceCriteria.length).toBeGreaterThan(0); + }); +}); + +describe('addRecord — the Studio forms offer exactly the two live keys', () => { + type Entry = { field?: string; fields?: Entry[] }; + const find = (form: unknown, field: string): Entry | undefined => { + for (const section of (form as { sections: Array<{ fields: Entry[] }> }).sections) { + const hit = section.fields.find((f) => f.field === field); + if (hit) return hit; + } + return undefined; + }; + + it.each([ + ['view form', viewForm, 'addRecord'], + ['page form', pageForm, 'interfaceConfig.addRecord'], + ])('the %s', (_, form, field) => { + const entry = find(form, field) ?? find(form, 'addRecord'); + expect(entry, `${field} is offered`).toBeDefined(); + expect(entry!.fields?.map((f) => f.field)).toEqual(['enabled', 'position']); + }); +}); + +// ─── Tree-scoped absence, inside the radius the package already declares ─── +// +// `tsc` sweeps the TYPED authoring sites; this walk covers every text file +// under the five repo roots `scripts/cross-package-test-inputs.mjs` declares +// for `@objectstack/spec#test` (mirrored in `turbo.json`), with the example +// apps' own `src/` trees. It judges the AUTHORING SHAPE: an `addRecord` object +// literal (TS / JS / JSON, or a fenced block in Markdown) whose own braces hold +// a `mode` or `formView` key. The bound, stated: a block assembled by spread or +// a computed key, a block containing a nested object before the key, and YAML +// are outside what this walk sees. +describe('tree-scoped absence: no `addRecord` block inside the declared radius still authors `mode` or `formView`', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx']); + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + const AUTHORING = /["']?addRecord["']?\s*:\s*\{[^{}]*?["']?\b(?:mode|formView)["']?\s*:/; + /** Inline code spans are prose; newline-bounded, so a fenced example is still judged. */ + const stripInlineCode = (text: string): string => text.replace(/`[^`\n]*`/g, ''); + const judge = (text: string): boolean => AUTHORING.test(stripInlineCode(text)); + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4): every entry's JOB is to + * spell the retired keys. + */ + const EXCLUDED = new Set([ + // This pin authors the keys to assert their refusal and their conversion. + THIS_FILE, + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversions' fixtures author the pre-retirement block on purpose. + 'packages/spec/src/conversions/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + ]; + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matcher recognises an authoring and ignores its neighbours (anti-vacuity)', () => { + expect(judge("addRecord: { enabled: true, mode: 'form', formView: 'create' },")).toBe(true); + expect(judge("addRecord: {\n enabled: true,\n formView: 'default',\n},")).toBe(true); + expect(judge('{ "addRecord": { "enabled": true, "mode": "inline" } }')).toBe(true); + expect(judge("Prose.\n\n```ts\nlist: { addRecord: { mode: 'modal' } }\n```\n")).toBe(true); + // Neighbours that must NOT match. + expect(judge("addRecord: { enabled: true, position: 'top' },")).toBe(false); + expect(judge('a list once wrote `addRecord: { mode: \'form\' }`.')).toBe(false); + expect(judge("navigation: { mode: 'drawer' }, addRecord: { enabled: true }")).toBe(false); + expect(judge('"ui/AddRecordConfig:mode [RETIRED]",')).toBe(false); + expect(judge('mode: retiredKey(ADD_RECORD_MODE_RETIRED),')).toBe(false); + }); + + it('no `addRecord` block authoring `mode` or `formView` survives inside the declared radius', () => { + const offenders: string[] = []; + let visited = 0; + let mentioning = 0; + const skipDir = (name: string) => SKIPPED_DIRS.has(name) || name.startsWith('.'); + for (const root of WALK_ROOTS) { + for (const full of listRepoWalkFiles(path.join(REPO_ROOT, root), { skipDir })) { + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + const name = path.basename(full); + const ext = path.extname(name); + const scanned = rel.startsWith('examples/') + ? EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel) + : SCANNED_EXT.has(ext); + if (!scanned) continue; + if (name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(name)) continue; + visited += 1; + const text = readIfPresent(full); + if (text === undefined) continue; + if (text.includes('addRecord')) mentioning += 1; + if (judge(text)) offenders.push(rel); + } + } + // Anti-vacuity: the walk covered the tree, and files that CAN hold the + // block were really judged. + expect(visited).toBeGreaterThan(1000); + expect(mentioning).toBeGreaterThan(5); + expect(offenders, 'an `addRecord` block authoring `mode` / `formView` means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/ui/page.form.ts b/packages/spec/src/ui/page.form.ts index c6d323bd4ae..503dbd1e910 100644 --- a/packages/spec/src/ui/page.form.ts +++ b/packages/spec/src/ui/page.form.ts @@ -162,7 +162,15 @@ export const pageForm = defineForm({ }, // ── User actions ── { field: 'userActions', type: 'composite', disclosure: 'popover', helpText: 'Toolbar toggles (search, sort, filter, row height)' }, - { field: 'addRecord', type: 'composite', disclosure: 'popover', helpText: 'Add-record entry point' }, + // [#24692] The two live keys only: `mode` / `formView` are tombstones + // on `AddRecordConfigSchema`, and `enabled` is required. + { + field: 'addRecord', + type: 'composite', + disclosure: 'popover', + helpText: 'Add-record entry point', + fields: [{ field: 'enabled' }, { field: 'position' }], + }, // The list page's own empty state — the list view's `EmptyStateSchema`, // by reference, so the same popover shape as `appearance` above. The // console's list page draws it in place of the source view's, whole diff --git a/packages/spec/src/ui/page.test.ts b/packages/spec/src/ui/page.test.ts index 9c3cfad6b3d..798327fbe5e 100644 --- a/packages/spec/src/ui/page.test.ts +++ b/packages/spec/src/ui/page.test.ts @@ -999,7 +999,6 @@ describe('InterfacePageConfigSchema', () => { addRecord: { enabled: true, position: 'bottom', - mode: 'inline', }, showRecordCount: true, allowPrinting: true, @@ -1066,7 +1065,6 @@ describe('PageSchema with interfaceConfig', () => { addRecord: { enabled: true, position: 'bottom', - mode: 'inline', }, showRecordCount: true, allowPrinting: true, diff --git a/packages/spec/src/ui/view.form.ts b/packages/spec/src/ui/view.form.ts index c49d9e02a53..a067590c1cb 100644 --- a/packages/spec/src/ui/view.form.ts +++ b/packages/spec/src/ui/view.form.ts @@ -180,7 +180,17 @@ export const viewForm = defineForm({ // entries, each a tab in the saved-view switcher. { field: 'appearance', type: 'composite', helpText: 'allowedVisualizations: which renderers users may switch between' }, { field: 'userActions', type: 'composite', helpText: 'Toolbar toggles: sort / search / filter / row height' }, - { field: 'addRecord', type: 'composite' }, + // [#24692] Spelled out so the form offers exactly the two live keys: + // `mode` and `formView` are `retiredKey()` tombstones on + // `AddRecordConfigSchema` (no renderer ever read them — the Add button + // opens the create form, `formViews.create` when declared), and + // `enabled` is required, so an Add-record block always says whether it + // is on. + { + field: 'addRecord', + type: 'composite', + fields: [{ field: 'enabled' }, { field: 'position' }], + }, { field: 'showRecordCount' }, ], }, diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index d5f8fa72541..9f7849ae084 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -3626,37 +3626,17 @@ describe('UserFiltersSchema (ADR-0047)', () => { }); describe('AddRecordConfigSchema', () => { - it('should apply default values', () => { - const config = AddRecordConfigSchema.parse({}); - expect(config.enabled).toBe(true); - expect(config.position).toBe('bottom'); - expect(config.mode).toBe('inline'); - expect(config.formView).toBeUndefined(); - }); - - it('should accept full configuration', () => { - const config = AddRecordConfigSchema.parse({ - enabled: true, - position: 'top', - mode: 'form', - formView: 'quick_create', - }); - expect(config.position).toBe('top'); - expect(config.mode).toBe('form'); - expect(config.formView).toBe('quick_create'); + // The #24692 retirement (`enabled` required, `position` default `top`, + // `mode` / `formView` tombstoned) is pinned in + // `add-record-config-retirement.test.ts`; these are the live-shape basics. + it('defaults `position` to `top` and materializes nothing else', () => { + expect(AddRecordConfigSchema.parse({ enabled: true })).toEqual({ enabled: true, position: 'top' }); }); it('should accept all position values', () => { const positions = ['top', 'bottom', 'both'] as const; positions.forEach(position => { - expect(() => AddRecordConfigSchema.parse({ position })).not.toThrow(); - }); - }); - - it('should accept all mode values', () => { - const modes = ['inline', 'form', 'modal'] as const; - modes.forEach(mode => { - expect(() => AddRecordConfigSchema.parse({ mode })).not.toThrow(); + expect(AddRecordConfigSchema.parse({ enabled: true, position })).toEqual({ enabled: true, position }); }); }); @@ -3708,12 +3688,9 @@ describe('ListViewSchema — Airtable Interface parity fields', () => { addRecord: { enabled: true, position: 'bottom', - mode: 'form', - formView: 'quick_create', }, }); - expect(listView.addRecord?.mode).toBe('form'); - expect(listView.addRecord?.formView).toBe('quick_create'); + expect(listView.addRecord).toEqual({ enabled: true, position: 'bottom' }); }); it('should accept list view with showRecordCount', () => { @@ -3761,7 +3738,6 @@ describe('ListViewSchema — Airtable Interface parity fields', () => { addRecord: { enabled: true, position: 'bottom', - mode: 'inline', }, showRecordCount: true, allowPrinting: true, diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 1e5e34e05ab..76e28a374d3 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1773,20 +1773,68 @@ export const UserFiltersSchema = lazySchema(() => strictObject({ .describe('Let end users add their own tab after the presets (tabs element): the affordance asks for a name and snapshots the filters currently applied as a new tab. SESSION-SCOPED — an added tab lives only for the current mount, is never written back as metadata (ADR-0047), and carries a remove control the authored presets do not. Page lists only — object views use `listViews` for named presets'), }).describe('End-user quick-filter configuration (Airtable "User filters" parity)')); +/** + * The prescription an author meets on an `addRecord` block that omits + * `enabled` (#24692, ruling letter B′). The key is REQUIRED with no default: + * the renderer draws the Add button only for an explicit `enabled: true`, so + * the old `.default(true)` promised a button that `addRecord: {}` never drew. + */ +const ADD_RECORD_ENABLED_REQUIRED = + '`addRecord.enabled` is required since @objectstack/spec 18.1.0 (ADR-0049 enforce-or-remove) — an ' + + '`addRecord` block that omits it draws no Add button, so the ' + + 'block must say whether the entry point is on. Write `enabled: true` to show the Add button ' + + '(`position` places it: `top`, the default, `bottom` or `both`), or `enabled: false` to declare ' + + 'it off. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; ' + + '`--write` applies the ones it can prove, and you apply the rest by hand.'; + +/** + * `addRecord.mode` / `addRecord.formView` — retired (#24692, ADR-0049 + * enforce-or-remove; ruling letter B′). No renderer ever read either key: + * every Add opens the object's create form, chosen by convention — + * `formViews.create` when the object's view container declares one, else the + * default form. How that form opens stays the renderer's. D2: + * `add-record-mode-form-view-removed`. + */ +const ADD_RECORD_MODE_RETIRED = + '`addRecord.mode` was removed in @objectstack/spec 18.1.0 (ADR-0049 enforce-or-remove) — no ' + + 'renderer ever read it: the Add button always opens the create form, never an inline row or a ' + + 'mode of its own choosing. Delete the key; the create form opens the way the object list\'s New ' + + 'opens it. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; ' + + '`--write` applies the ones it can prove, and you apply the rest by hand.'; + +const ADD_RECORD_FORM_VIEW_RETIRED = + '`addRecord.formView` was removed in @objectstack/spec 18.1.0 (ADR-0049 enforce-or-remove) — no ' + + 'renderer ever read it: the Add button opens the create form chosen by convention, never a named ' + + 'form. Delete the key, and to give the Add button its own form declare it as `formViews.create` ' + + 'on the object\'s view container (without one, the default form is used). ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` ' + + 'applies the ones it can prove, and you apply the rest by hand.'; + /** * Add Record Configuration Schema (Airtable Interface parity) * Configures the "Add Record" entry point for a list view. * + * [#24692] Ruling letter B′ made this block say what renders: `enabled` is + * REQUIRED (no default — `addRecord: {}` is refused instead of drawing + * nothing), `position` defaults to `top` (where the renderer places an absent + * position), and `mode` / `formView`, which no renderer read, are tombstoned. + * The create form is chosen by convention: `formViews.create` when declared, + * else `form` / `formViews.default`. The stored-row half of `enabled` is the D2 + * conversion `add-record-enabled-defaulted`, which writes the old default + * (`true`) onto a block that omits it. + * * @see Airtable Interface → "+ Add record" button */ export const AddRecordConfigSchema = lazySchema(() => strictObject({ surface: 'this add record configuration', history: VIEW_HISTORY, }, { - enabled: z.boolean().default(true).describe('Show the add record entry point'), - position: z.enum(['top', 'bottom', 'both']).default('bottom').describe('Position of the add record button'), - mode: z.enum(['inline', 'form', 'modal']).default('inline').describe('How to add a new record'), - formView: z.string().optional().describe('Named form view to use when mode is "form" or "modal"'), + enabled: z + .boolean({ error: (issue) => (issue.input === undefined ? ADD_RECORD_ENABLED_REQUIRED : undefined) }) + .describe('Show the add record entry point — REQUIRED: `true` draws the Add button, `false` declares it off'), + position: z.enum(['top', 'bottom', 'both']).default('top').describe('Position of the add record button: `top` (the toolbar, the default), `bottom` (under the list) or `both`'), + mode: retiredKey(ADD_RECORD_MODE_RETIRED), + formView: retiredKey(ADD_RECORD_FORM_VIEW_RETIRED), }).describe('Add record entry point configuration')); /** From 1d71a3d4d58e1bc06b77debf883593b8eb116283 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 17:20:47 +0000 Subject: [PATCH 2/7] chore(spec): regenerate artifacts for the addRecord retirement (WIP) Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 4 +-- content/docs/references/data/object.mdx | 2 +- content/docs/references/ui/page.mdx | 12 +++---- content/docs/references/ui/view.mdx | 36 ++++++++++---------- packages/spec/authorable-defaults/ui.json | 4 +-- packages/spec/authorable-surface/ui.json | 4 +-- packages/spec/scripts/lib/default-changes.ts | 25 ++++++++++++++ 7 files changed, 55 insertions(+), 32 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 893a5685b52..6495b7f1013 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1863,7 +1863,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | | **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | +| **addRecord** | `{ enabled: boolean; position?: Enum<'top' \| 'bottom' \| 'both'> }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | Empty state configuration when no records found | @@ -1949,7 +1949,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | | **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | +| **addRecord** | `{ enabled: boolean; position?: Enum<'top' \| 'bottom' \| 'both'> }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | Empty state configuration when no records found | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 61bf66bb979..f2f11ce2cc4 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -409,7 +409,7 @@ const result = ApiMethod.parse(data); | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | | **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | +| **addRecord** | `{ enabled: boolean; position?: Enum<'top' \| 'bottom' \| 'both'> }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | Empty state configuration when no records found | diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 17f781a5859..1c8da8abc93 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -95,7 +95,7 @@ Interface-level page configuration (Airtable parity) | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | | **userFilters** | `{ element?: Enum<'dropdown' \| 'tabs' \| 'toggle'>; fields?: object[]; tabs?: object[]; showAllRecords?: boolean; … }` | optional | End-user quick-filter bar for this page (overrides the source view's userFilters) | | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles | -| **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | +| **addRecord** | `{ enabled: boolean; position?: Enum<'top' \| 'bottom' \| 'both'> }` | optional | Add record entry point configuration | | **buttons** | `string[]` | optional | Toolbar buttons — names of the source object's actions to surface in the page toolbar | | **recordAction** | `Enum<'drawer' \| 'page' \| 'modal' \| 'none'>` | optional | How clicking a record opens its detail (drawer \| page \| modal \| none). Default: drawer | | **showRecordCount** | `boolean` | optional | Show record count at page bottom | @@ -177,10 +177,10 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | optional (default: `true`) | Show the add record entry point | -| **position** | `Enum<'top' \| 'bottom' \| 'both'>` | optional (default: `"bottom"`) | Position of the add record button | -| **mode** | `Enum<'inline' \| 'form' \| 'modal'>` | optional (default: `"inline"`) | How to add a new record | -| **formView** | `string` | optional | Named form view to use when mode is "form" or "modal" | +| **enabled** | `boolean` | ✅ | Show the add record entry point — REQUIRED: `true` draws the Add button, `false` declares it off | +| **position** | `Enum<'top' \| 'bottom' \| 'both'>` | optional (default: `"top"`) | Position of the add record button: `top` (the toolbar, the default), `bottom` (under the list) or `both` | +| **mode** | `never` | optional | [REMOVED] `addRecord.mode` was removed in @objectstack/spec 18.1.0 (ADR-0049 enforce-or-remove) — no renderer ever read it: the Add button always opens the create form, never an inline row or a mode of its own choosing. Delete the key; the create form opens the way the object list's New opens it. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **formView** | `never` | optional | [REMOVED] `addRecord.formView` was removed in @objectstack/spec 18.1.0 (ADR-0049 enforce-or-remove) — no renderer ever read it: the Add button opens the create form chosen by convention, never a named form. Delete the key, and to give the Add button its own form declare it as `formViews.create` on the object's view container (without one, the default form is used). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `InterfacePageConfig.emptyState` @@ -266,7 +266,7 @@ View filter rule | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | | **userFilters** | `{ element?: Enum<'dropdown' \| 'tabs' \| 'toggle'>; fields?: object[]; tabs?: object[]; showAllRecords?: boolean; … }` | optional | End-user quick-filter bar for this page (overrides the source view's userFilters) | | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles | -| **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | +| **addRecord** | `{ enabled: boolean; position?: Enum<'top' \| 'bottom' \| 'both'> }` | optional | Add record entry point configuration | | **buttons** | `string[]` | optional | Toolbar buttons — names of the source object's actions to surface in the page toolbar | | **recordAction** | `Enum<'drawer' \| 'page' \| 'modal' \| 'none'>` | optional | How clicking a record opens its detail (drawer \| page \| modal \| none). Default: drawer | | **showRecordCount** | `boolean` | optional | Show record count at page bottom | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index c7d19516020..1cb01d57119 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -76,10 +76,10 @@ Add record entry point configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | optional (default: `true`) | Show the add record entry point | -| **position** | `Enum<'top' \| 'bottom' \| 'both'>` | optional (default: `"bottom"`) | Position of the add record button | -| **mode** | `Enum<'inline' \| 'form' \| 'modal'>` | optional (default: `"inline"`) | How to add a new record | -| **formView** | `string` | optional | Named form view to use when mode is "form" or "modal" | +| **enabled** | `boolean` | ✅ | Show the add record entry point — REQUIRED: `true` draws the Add button, `false` declares it off | +| **position** | `Enum<'top' \| 'bottom' \| 'both'>` | optional (default: `"top"`) | Position of the add record button: `top` (the toolbar, the default), `bottom` (under the list) or `both` | +| **mode** | `never` | optional | [REMOVED] `addRecord.mode` was removed in @objectstack/spec 18.1.0 (ADR-0049 enforce-or-remove) — no renderer ever read it: the Add button always opens the create form, never an inline row or a mode of its own choosing. Delete the key; the create form opens the way the object list's New opens it. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **formView** | `never` | optional | [REMOVED] `addRecord.formView` was removed in @objectstack/spec 18.1.0 (ADR-0049 enforce-or-remove) — no renderer ever read it: the Add button opens the create form chosen by convention, never a named form. Delete the key, and to give the Add button its own form declare it as `formViews.create` on the object's view container (without one, the default form is used). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | --- @@ -806,7 +806,7 @@ Map view configuration | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | | **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | +| **addRecord** | `{ enabled: boolean; position?: Enum<'top' \| 'bottom' \| 'both'> }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | Empty state configuration when no records found | @@ -1090,10 +1090,10 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | optional (default: `true`) | Show the add record entry point | -| **position** | `Enum<'top' \| 'bottom' \| 'both'>` | optional (default: `"bottom"`) | Position of the add record button | -| **mode** | `Enum<'inline' \| 'form' \| 'modal'>` | optional (default: `"inline"`) | How to add a new record | -| **formView** | `string` | optional | Named form view to use when mode is "form" or "modal" | +| **enabled** | `boolean` | ✅ | Show the add record entry point — REQUIRED: `true` draws the Add button, `false` declares it off | +| **position** | `Enum<'top' \| 'bottom' \| 'both'>` | optional (default: `"top"`) | Position of the add record button: `top` (the toolbar, the default), `bottom` (under the list) or `both` | +| **mode** | `never` | optional | [REMOVED] `addRecord.mode` was removed in @objectstack/spec 18.1.0 (ADR-0049 enforce-or-remove) — no renderer ever read it: the Add button always opens the create form, never an inline row or a mode of its own choosing. Delete the key; the create form opens the way the object list's New opens it. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **formView** | `never` | optional | [REMOVED] `addRecord.formView` was removed in @objectstack/spec 18.1.0 (ADR-0049 enforce-or-remove) — no renderer ever read it: the Add button opens the create form chosen by convention, never a named form. Delete the key, and to give the Add button its own form declare it as `formViews.create` on the object's view container (without one, the default form is used). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `ListView.emptyState` @@ -1192,7 +1192,7 @@ View filter rule | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | | **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | +| **addRecord** | `{ enabled: boolean; position?: Enum<'top' \| 'bottom' \| 'both'> }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | Empty state configuration when no records found | @@ -1467,10 +1467,10 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | optional (default: `true`) | Show the add record entry point | -| **position** | `Enum<'top' \| 'bottom' \| 'both'>` | optional (default: `"bottom"`) | Position of the add record button | -| **mode** | `Enum<'inline' \| 'form' \| 'modal'>` | optional (default: `"inline"`) | How to add a new record | -| **formView** | `string` | optional | Named form view to use when mode is "form" or "modal" | +| **enabled** | `boolean` | ✅ | Show the add record entry point — REQUIRED: `true` draws the Add button, `false` declares it off | +| **position** | `Enum<'top' \| 'bottom' \| 'both'>` | optional (default: `"top"`) | Position of the add record button: `top` (the toolbar, the default), `bottom` (under the list) or `both` | +| **mode** | `never` | optional | [REMOVED] `addRecord.mode` was removed in @objectstack/spec 18.1.0 (ADR-0049 enforce-or-remove) — no renderer ever read it: the Add button always opens the create form, never an inline row or a mode of its own choosing. Delete the key; the create form opens the way the object list's New opens it. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **formView** | `never` | optional | [REMOVED] `addRecord.formView` was removed in @objectstack/spec 18.1.0 (ADR-0049 enforce-or-remove) — no renderer ever read it: the Add button opens the create form chosen by convention, never a named form. Delete the key, and to give the Add button its own form declare it as `formViews.create` on the object's view container (without one, the default form is used). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | ### Nested Shape: `ObjectListView.emptyState` @@ -1769,7 +1769,7 @@ Tab configuration for multi-tab view interface | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | | **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | +| **addRecord** | `{ enabled: boolean; position?: Enum<'top' \| 'bottom' \| 'both'> }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | Empty state configuration when no records found | @@ -1855,7 +1855,7 @@ Tab configuration for multi-tab view interface | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | | **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | +| **addRecord** | `{ enabled: boolean; position?: Enum<'top' \| 'bottom' \| 'both'> }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | Empty state configuration when no records found | @@ -2065,7 +2065,7 @@ This schema accepts one of the following structures: | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | | **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | +| **addRecord** | `{ enabled: boolean; position?: Enum<'top' \| 'bottom' \| 'both'> }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | Empty state configuration when no records found | @@ -2244,7 +2244,7 @@ This schema accepts one of the following structures: | **userActions** | `{ sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … }` | optional | User action toggles for the view toolbar | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | | **tabs** | `never` | optional | [REMOVED] `view.list.tabs` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's `listViews` instead: the tab's `name` becomes the entry's key, its `label` the entry's `label`, and its `filter` rules join the view's own `filter` on that entry (copy the view's `columns` too); a tab whose `view` already named a list view needs nothing more. Every `listViews` entry renders as a tab in the switcher. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **addRecord** | `{ enabled?: boolean; position?: Enum<'top' \| 'bottom' \| 'both'>; mode?: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration | +| **addRecord** | `{ enabled: boolean; position?: Enum<'top' \| 'bottom' \| 'both'> }` | optional | Add record entry point configuration | | **showRecordCount** | `boolean` | optional | Show record count at the bottom of the list | | **allowPrinting** | `boolean` | optional | Allow users to print the view | | **emptyState** | `{ title?: string \| Record; message?: string \| Record; icon?: string; link?: object }` | optional | Empty state configuration when no records found | diff --git a/packages/spec/authorable-defaults/ui.json b/packages/spec/authorable-defaults/ui.json index 6232d146247..45288470faa 100644 --- a/packages/spec/authorable-defaults/ui.json +++ b/packages/spec/authorable-defaults/ui.json @@ -6,9 +6,7 @@ "ui/Action:type = \"script\"", "ui/ActionAi:exposed = false", "ui/ActionParam:required = false", - "ui/AddRecordConfig:enabled = true", - "ui/AddRecordConfig:mode = \"inline\"", - "ui/AddRecordConfig:position = \"bottom\"", + "ui/AddRecordConfig:position = \"top\"", "ui/App:active = true", "ui/App:isDefault = false", "ui/AppContextSelector:allValue = \"\"", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 44d7ae5d700..56408327116 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -161,8 +161,8 @@ "ui/ActionSession:roles", "ui/ActionSession:userId", "ui/AddRecordConfig:enabled", - "ui/AddRecordConfig:formView", - "ui/AddRecordConfig:mode", + "ui/AddRecordConfig:formView [RETIRED]", + "ui/AddRecordConfig:mode [RETIRED]", "ui/AddRecordConfig:position", "ui/App:_lock", "ui/App:_lockDocsUrl", diff --git a/packages/spec/scripts/lib/default-changes.ts b/packages/spec/scripts/lib/default-changes.ts index 8dc0e4fccc4..b25cb561a8b 100644 --- a/packages/spec/scripts/lib/default-changes.ts +++ b/packages/spec/scripts/lib/default-changes.ts @@ -621,6 +621,31 @@ export const DEFAULT_CHANGES_BY_MAJOR: Readonly Date: Tue, 20 Oct 2026 17:22:43 +0000 Subject: [PATCH 3/7] test(spec): pin the addRecord retirement at every door (WIP) Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- .../ui/add-record-config-retirement.test.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/spec/src/ui/add-record-config-retirement.test.ts b/packages/spec/src/ui/add-record-config-retirement.test.ts index 1ee59044bce..5530472be36 100644 --- a/packages/spec/src/ui/add-record-config-retirement.test.ts +++ b/packages/spec/src/ui/add-record-config-retirement.test.ts @@ -274,20 +274,21 @@ describe('addRecord — the ADR-0087 ledger rows', () => { describe('addRecord — the Studio forms offer exactly the two live keys', () => { type Entry = { field?: string; fields?: Entry[] }; + /** Every entry named `field`, at any depth (the page form nests it under `interfaceConfig`). */ + const findAll = (entries: Entry[], field: string): Entry[] => + entries.flatMap((e) => [...(e.field === field ? [e] : []), ...findAll(e.fields ?? [], field)]); const find = (form: unknown, field: string): Entry | undefined => { - for (const section of (form as { sections: Array<{ fields: Entry[] }> }).sections) { - const hit = section.fields.find((f) => f.field === field); - if (hit) return hit; - } - return undefined; + const hits = (form as { sections: Array<{ fields: Entry[] }> }).sections.flatMap((s) => findAll(s.fields, field)); + expect(hits, `exactly one \`${field}\` entry`).toHaveLength(1); + return hits[0]; }; it.each([ - ['view form', viewForm, 'addRecord'], - ['page form', pageForm, 'interfaceConfig.addRecord'], - ])('the %s', (_, form, field) => { - const entry = find(form, field) ?? find(form, 'addRecord'); - expect(entry, `${field} is offered`).toBeDefined(); + ['view form', viewForm], + ['page form', pageForm], + ])('the %s', (_, form) => { + const entry = find(form, 'addRecord'); + expect(entry, 'addRecord is offered').toBeDefined(); expect(entry!.fields?.map((f) => f.field)).toEqual(['enabled', 'position']); }); }); From 5de319d6bd9bf9dae7be2cfe8407cba4a892ba4c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 17:26:18 +0000 Subject: [PATCH 4/7] test(spec): run the addRecord retirement pin in the repo project (WIP) Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- packages/spec/vitest.repo-tests.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index d843676e634..58a14eaad89 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -55,6 +55,7 @@ "src/system/email-template-category-is-system-retirement.test.ts", "src/system/email-template-floor-locale-parity.pin.test.ts", "src/ui/action-requires-confirmation-docblock.pin.test.ts", + "src/ui/add-record-config-retirement.test.ts", "src/ui/component-action-row-patch-retirement.test.ts", "src/ui/component-marketplace-installed-list-retirement.test.ts", "src/ui/component-record-json-retirement.test.ts", From 1fffa2327ee86f8802890064e0d05067a4f176da Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 17:27:26 +0000 Subject: [PATCH 5/7] docs(changeset): the addRecord retirement (WIP) Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- .changeset/24692-add-record-retired.md | 43 ++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .changeset/24692-add-record-retired.md diff --git a/.changeset/24692-add-record-retired.md b/.changeset/24692-add-record-retired.md new file mode 100644 index 00000000000..1c7225c06f6 --- /dev/null +++ b/.changeset/24692-add-record-retired.md @@ -0,0 +1,43 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': patch +--- + +feat(spec)!: a list's `addRecord` block says what renders — `enabled` is required, `position` defaults to `top`, and `mode` / `formView` are retired + +Clause-②: yes (narrowing) + + + +**BREAKING** (an authoring narrowing), shipped as `minor` under the launch-window convention for breaking changes, and in 18.x under the standing rule for breaking narrowings: an ADR-0087 conversion carries every body that parsed under 18.0, so no stored row or built artifact is refused. + +`AddRecordConfigSchema` — the `addRecord` block on a list view, an object's or a view container's `listViews`, a list page's `interfaceConfig` and a page `list-view` node — declared defaults the renderer never applied (`enabled` `true`, `position` `bottom`, `mode` `inline`) and two keys no renderer read. The list's Add button is drawn only for an explicit `enabled: true`, an absent `position` renders at the top, and every Add opens the object's create form. The block now says exactly that: + +- **`enabled` is REQUIRED, with no default.** `addRecord: {}` is refused at parse (`os validate`, `os build`, the save doors) with a prescription naming the key, instead of drawing nothing. +- **`position` defaults to `top`**, where the renderer has always placed an absent position. Values stay `top` / `bottom` / `both`. No screen moves. +- **`mode` and `formView` are `retiredKey()` tombstones** (ADR-0049 enforce-or-remove): authoring either is a `tsc` error and a parse error carrying the prescription. The create form is chosen by convention instead: the create entry opens `formViews.create` when the object's view container declares it, else `form` (or `formViews.default`). How that form opens stays the renderer's. + +### FROM → TO + +| before | after | +| --- | --- | +| `addRecord: {}` (parsed as `enabled: true`, drew nothing) | refused: write `addRecord: { enabled: true }` | +| `addRecord: { position: 'bottom' }` | refused: write `addRecord: { enabled: true, position: 'bottom' }` | +| `addRecord: { enabled: true }` (parsed `position: 'bottom'`) | parses as `position: 'top'`, where it always rendered | +| `addRecord: { enabled: true, mode: 'form' }` (any `mode`) | `addRecord: { enabled: true }` | +| `addRecord: { enabled: true, formView: 'create' }` | `addRecord: { enabled: true }`, with the form declared as `formViews.create` on the object's view container | + +**The one-line fix:** delete `mode` and `formView`, and write `enabled: true` (or `enabled: false`) on every `addRecord` block. `os migrate meta --from 17` lists the edits for existing metadata sources; `--write` applies the ones it can prove, and you apply the rest by hand. + +### The ADR-0087 disposition + +- D2 `add-record-mode-form-view-removed` strips `mode` and `formView` from every `addRecord` block it reaches (`stack.views[]` in all three persisted spellings, `objects[].listViews.*`, `pages[].interfaceConfig` and every page `list-view` node), with a notice per key. Lossless in pixels: no renderer read either key. +- D2 `add-record-enabled-defaulted` writes `enabled: true`, the default the 18.0 spec declared, onto a block that omits `enabled`, with a notice. Measured before choosing it: no producer in this repository (the showcase's two blocks, the lint fixture) or in objectui's Studio design surface writes `addRecord` without `enabled`, so the conversion carries the narrowing with a zero measured population. **One visible consequence, stated:** a stored block that omitted `enabled` drew no Add button, because the renderer read the raw body; converted, it draws the button its declaration always promised. To keep a list without one, write `enabled: false` or drop the block. An explicit `enabled` value is never touched. +- Both are retired from the load path: a live author is refused at parse and taught; stored rows, built artifacts inside their declared-floor window and `os migrate meta --from 17` replay them. +- `RETIRED_KEYS_BY_MAJOR[18]` gains `ui/AddRecordConfig:mode` and `ui/AddRecordConfig:formView`; the D3 entries are `add-record-mode-form-view-retired` and `add-record-enabled-required`; the default moves (`enabled` `true` → none, `position` `bottom` → `top`) are declared in `DEFAULT_CHANGES_BY_MAJOR[18]`. + +### Also in this change + +- The Studio view form and page form offer exactly `enabled` and `position` for `addRecord`. +- `@objectstack/lint`: `os explain list-view-field-unknown` no longer names `addRecord.formView` as a position another rule checks. +- The showcase's two `addRecord` blocks and the docs (`ui/create-vs-edit-form`, `ui/views`) drop `mode` / `formView` and state the create-form convention. From 4f719f2f66f9ac6a3f4a046a60854ed406aff44a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 17:40:08 +0000 Subject: [PATCH 6/7] chore(i18n): label the addRecord composite's two live keys (WIP) Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- .../apps/translations/en.metadata-forms.generated.ts | 12 ++++++++++++ .../translations/es-ES.metadata-forms.generated.ts | 12 ++++++++++++ .../translations/ja-JP.metadata-forms.generated.ts | 12 ++++++++++++ .../translations/zh-CN.metadata-forms.generated.ts | 12 ++++++++++++ 4 files changed, 48 insertions(+) diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 6fee6735a78..da4b54354dd 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -1157,6 +1157,12 @@ export const enMetadataForms: NonNullable = { addRecord: { label: "Add Record" }, + "addRecord.enabled": { + label: "Enabled" + }, + "addRecord.position": { + label: "Position" + }, showRecordCount: { label: "Show Record Count" }, @@ -1304,6 +1310,12 @@ export const enMetadataForms: NonNullable = { label: "Add Record", helpText: "Add-record entry point" }, + "interfaceConfig.addRecord.enabled": { + label: "Enabled" + }, + "interfaceConfig.addRecord.position": { + label: "Position" + }, "interfaceConfig.emptyState": { label: "Empty State", helpText: "What the page shows when its list has no rows: a heading, a message, an icon and one link. Set here, it replaces the source view's empty state." diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 04a4bb290ed..cc8eb941495 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -1157,6 +1157,12 @@ export const esESMetadataForms: NonNullable = addRecord: { label: "Agregar registro" }, + "addRecord.enabled": { + label: "Habilitado" + }, + "addRecord.position": { + label: "Posición" + }, showRecordCount: { label: "Mostrar recuento de registros" }, @@ -1304,6 +1310,12 @@ export const esESMetadataForms: NonNullable = label: "Agregar registro", helpText: "Punto de entrada para agregar registros" }, + "interfaceConfig.addRecord.enabled": { + label: "Habilitado" + }, + "interfaceConfig.addRecord.position": { + label: "Posición" + }, "interfaceConfig.emptyState": { label: "Estado vacío", helpText: "Lo que muestra la página cuando su lista no tiene registros: un título, un mensaje, un icono y un enlace. Si se define aquí, sustituye al estado vacío de la vista de origen." diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index fe2a353a84c..fcc00141656 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -1157,6 +1157,12 @@ export const jaJPMetadataForms: NonNullable = addRecord: { label: "レコードを追加" }, + "addRecord.enabled": { + label: "有効" + }, + "addRecord.position": { + label: "位置" + }, showRecordCount: { label: "レコード数を表示" }, @@ -1304,6 +1310,12 @@ export const jaJPMetadataForms: NonNullable = label: "レコードを追加", helpText: "レコード追加の入口" }, + "interfaceConfig.addRecord.enabled": { + label: "有効" + }, + "interfaceConfig.addRecord.position": { + label: "位置" + }, "interfaceConfig.emptyState": { label: "空の状態", helpText: "リストにレコードがないときにページに表示する内容:見出し、メッセージ、アイコン、リンク 1 つ。ここで設定すると、ソースビューの空の状態を置き換えます。" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 32e12c44f45..5ca32b8ca98 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -1157,6 +1157,12 @@ export const zhCNMetadataForms: NonNullable = addRecord: { label: "添加记录" }, + "addRecord.enabled": { + label: "已启用" + }, + "addRecord.position": { + label: "位置" + }, showRecordCount: { label: "显示记录数" }, @@ -1304,6 +1310,12 @@ export const zhCNMetadataForms: NonNullable = label: "添加记录", helpText: "添加记录的入口" }, + "interfaceConfig.addRecord.enabled": { + label: "已启用" + }, + "interfaceConfig.addRecord.position": { + label: "位置" + }, "interfaceConfig.emptyState": { label: "空状态", helpText: "列表没有记录时页面显示的内容:标题、说明、图标和一个链接。在此设置后,它将替换源视图的空状态。" From fc643e459f6327a3840106256a3eb3a6f575a800 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:06:32 +0000 Subject: [PATCH 7/7] test(platform-objects): the translated-label control counts the addRecord composite's four new rows Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- .../object-lifecycle-panel-echo-decisions.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 4493e7398d3..1b6e8479bd3 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1208,7 +1208,11 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // key (retired — declared as the `userFilters.fields` shorthand, read as a // Filter-panel whitelist), taking its label — authored in all three // locales — out of the catalog. - expect(translated.length, `${locale} positive control`).toBe(673); + // 677 since #24692: the view form's and the page form's `addRecord` + // composites spell out their two live sub-rows (`enabled`, `position`; + // `mode` / `formView` were retired), four new row labels authored in all + // three locales. + expect(translated.length, `${locale} positive control`).toBe(677); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the