diff --git a/.changeset/object-editmode-declared.md b/.changeset/object-editmode-declared.md new file mode 100644 index 0000000000..2ea81b81ac --- /dev/null +++ b/.changeset/object-editmode-declared.md @@ -0,0 +1,27 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): declare `editMode?: 'modal' | 'page'` on the object document (#11408) + +Accept-set **widening** — no existing document changes meaning and nothing is +removed. Maintainer ruling 2026-08-24 (declare, the #10144 declare-or-rule-out +family): objectui's shipped runtime reads `objectDef.editMode` (record-edit +routing: modal form vs a dedicated `/record/:id/edit` route) and its CHANGELOG +announces the key to authors, while the spec's strict parse rejected it with +`unrecognized_keys` — so an author following objectui's documentation was +refused by every spec-validating path and the key only worked through data +sources that skip validation. + +The object document now declares it beside the other display hints +(`nameField`, `highlightFields`, `stageField`): an optional cross-renderer +edit-interaction intent — `'modal'` opens the edit form as a dialog over the +current view, `'page'` navigates to a dedicated full-page edit route, absent +lets the renderer pick its own default (objectui defaults to modal). Values +outside the enum are rejected as a located value error at `editMode`. + +Consumer-side follow-up (not in this change): objectui retires its +`ObjectSchemaClientExtensions.editMode` client-extension member and lets the +spec derivation carry the key — its pinned rejection tests flip by design. +That retirement is **release-gated** on the `@objectstack/spec` release +containing this change (per the recorded ruling), not merely on this merge. diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index b4531354bc..981bc79695 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -136,6 +136,7 @@ const result = ApiMethod.parse(data); | **titleFormat** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | +| **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (#11408, #10144 family). | | **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused (#6674) — mirror the value onto a stored text field and declare that. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index f2d1806ef7..44bb0ecc1b 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -623,6 +623,7 @@ "data/Object:datasource", "data/Object:description", "data/Object:displayNameField", + "data/Object:editMode", "data/Object:enable", "data/Object:external", "data/Object:externalSharingModel", diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index f132d29b0a..b585a04e5d 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -45,6 +45,10 @@ "proof": "packages/qa/dogfood/test/semantic-roles.dogfood.test.ts#semantic-roles-served", "note": "ADR-0085 semantic role: objectui plugin-detail detectStatusField drives the record:path stepper; string names the lifecycle field, false suppresses heuristic stage detection." }, + "editMode": { + "status": "live", + "note": "objectui record-edit routing: app-shell recordFormNavigation branches on editMode !== 'page' and AppContent handleEdit routes modal form vs dedicated /record/:id/edit route. Declared by the #11408 ruling (#10144 declare-or-rule-out family)." + }, "fieldGroups": { "status": "live", "proof": "packages/qa/dogfood/test/semantic-roles.dogfood.test.ts#semantic-roles-served", diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index deba75d157..80ce8b863d 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -27,7 +27,7 @@ for both corollaries. | Type | live | exp | dead | planned | classified | |---|---|---|---|---|---| -| `object` | 50 | 0 | 0 | 1 | 51 | +| `object` | 51 | 0 | 0 | 1 | 52 | | `field` | 89 | 0 | 0 | 2 | 91 | | `flow` | 34 | 0 | 6 | 0 | 40 | | `action` | 42 | 0 | 2 | 2 | 46 | @@ -58,4 +58,4 @@ for both corollaries. | `capability` | 12 | 0 | 0 | 0 | 12 | | `qa` | 4 | 0 | 5 | 0 | 9 | | `manifest` | 22 | 0 | 21 | 0 | 43 | -| **total** | **822** | **5** | **76** | **10** | **913** | +| **total** | **823** | **5** | **76** | **10** | **914** | diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 7070c7aef1..d247cc2592 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -1632,6 +1632,43 @@ describe('ObjectSchema semantic roles (ADR-0085)', () => { }); }); +describe('ObjectSchema editMode (#11408 — declared by maintainer ruling, #10144 family)', () => { + it('accepts both enum values through the strict parse and carries them on the output', () => { + // Full parse green (not merely "no unrecognized_keys"): the ruling adopts + // the key as authored surface, so a legal document must parse end to end. + const modal = ObjectSchema.parse({ name: 'task', fields: {}, editMode: 'modal' }); + expect(modal.editMode).toBe('modal'); + const page = ObjectSchema.parse({ name: 'task', fields: {}, editMode: 'page' }); + expect(page.editMode).toBe('page'); + }); + + it('stays optional: an object without editMode parses and the output carries no value', () => { + const parsed = ObjectSchema.parse({ name: 'task', fields: {} }); + expect(parsed.editMode).toBeUndefined(); + }); + + it('rejects a value outside the enum, as a VALUE error located at editMode — not unrecognized_keys', () => { + // Before #11408 the failure mode was `unrecognized_keys` at the top level + // (the key itself was unknown). Declaring the key moves the judgment to + // the VALUE: a bad spelling must now fail as an enum error at the + // `editMode` path, proving the key is recognised and its value contract + // is enforced. + for (const bad of ['drawer', 'inline', true, 3] as const) { + const result = ObjectSchema.safeParse({ name: 'task', fields: {}, editMode: bad }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].code).not.toBe('unrecognized_keys'); + expect(result.error.issues[0].path).toEqual(['editMode']); + } + } + }); + + it('is accepted by the authoring path (create()) as well as parse()', () => { + const created = ObjectSchema.create({ name: 'task', fields: {}, editMode: 'page' }); + expect(created.editMode).toBe('page'); + }); +}); + describe('ObjectSchema.fieldGroups', () => { it('should accept an object without fieldGroups (fully optional)', () => { const result = ObjectSchema.safeParse({ diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index d8a18902ad..83246cc9b7 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -2073,6 +2073,24 @@ const ObjectSchemaBase = strictObject( */ stageField: z.union([z.string(), z.literal(false)]).optional().describe('[ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed.'), + /** + * Cross-renderer EDIT-INTERACTION intent: whether a record of this object is + * edited in a modal dialog over the current view (`'modal'`) or on a + * dedicated full-page edit route (`'page'`). Like the other hints in this + * block it is an intent about the record, not pixel styling — any renderer + * can honour it. Absent = the renderer chooses its own default (objectui's + * shipped runtime treats anything but `'page'` as modal: + * `recordFormNavigation.ts` branches on `editMode !== 'page'`, and + * `AppContent`'s `handleEdit` dispatcher routes on it). + * + * Declared here by the #11408 maintainer ruling (the measured residue of the + * #10144 declare-or-rule-out census): objectui had published the key to + * authors (CHANGELOG + live runtime read) while this strict parse rejected + * it. objectui's `ObjectSchemaClientExtensions.editMode` mirror retires in a + * release-gated follow-up and the spec derivation carries it from then on. + */ + editMode: z.enum(['modal', 'page']).optional().describe("Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (#11408, #10144 family)."), + /** * Built-in List Views *