Skip to content

Commit f11fc61

Browse files
os-litantclaude
andauthored
feat(spec): declare editMode?: 'modal' | 'page' on the object document (#11408) (#12208)
Maintainer ruling 2026-08-24 (declare — #10144 declare-or-rule-out family): objectui's shipped runtime reads objectDef.editMode for record-edit routing while the spec's strict parse rejected the key with unrecognized_keys. The object document now declares it beside the other display hints; values outside the enum fail as a located value error at editMode. Accept-set widening; consumer-side retirement of ObjectSchemaClientExtensions.editMode is a release-gated objectui follow-up per the recorded ruling. Claude-Session: https://claude.ai/code/session_01NDGG54XF5gbTLdQzCtnaVV Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9abe4e4 commit f11fc61

7 files changed

Lines changed: 90 additions & 2 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): declare `editMode?: 'modal' | 'page'` on the object document (#11408)
6+
7+
Accept-set **widening** — no existing document changes meaning and nothing is
8+
removed. Maintainer ruling 2026-08-24 (declare, the #10144 declare-or-rule-out
9+
family): objectui's shipped runtime reads `objectDef.editMode` (record-edit
10+
routing: modal form vs a dedicated `/record/:id/edit` route) and its CHANGELOG
11+
announces the key to authors, while the spec's strict parse rejected it with
12+
`unrecognized_keys` — so an author following objectui's documentation was
13+
refused by every spec-validating path and the key only worked through data
14+
sources that skip validation.
15+
16+
The object document now declares it beside the other display hints
17+
(`nameField`, `highlightFields`, `stageField`): an optional cross-renderer
18+
edit-interaction intent — `'modal'` opens the edit form as a dialog over the
19+
current view, `'page'` navigates to a dedicated full-page edit route, absent
20+
lets the renderer pick its own default (objectui defaults to modal). Values
21+
outside the enum are rejected as a located value error at `editMode`.
22+
23+
Consumer-side follow-up (not in this change): objectui retires its
24+
`ObjectSchemaClientExtensions.editMode` client-extension member and lets the
25+
spec derivation carry the key — its pinned rejection tests flip by design.
26+
That retirement is **release-gated** on the `@objectstack/spec` release
27+
containing this change (per the recorded ruling), not merely on this merge.

content/docs/references/data/object.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ const result = ApiMethod.parse(data);
136136
| **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. |
137137
| **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. |
138138
| **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. |
139+
| **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). |
139140
| **listViews** | `Record<string, { name?: string; label?: string \| Record<string, string>; 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) |
140141
| **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. |
141142
| **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules |

packages/spec/authorable-surface/data.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,7 @@
623623
"data/Object:datasource",
624624
"data/Object:description",
625625
"data/Object:displayNameField",
626+
"data/Object:editMode",
626627
"data/Object:enable",
627628
"data/Object:external",
628629
"data/Object:externalSharingModel",

packages/spec/liveness/object.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@
4545
"proof": "packages/qa/dogfood/test/semantic-roles.dogfood.test.ts#semantic-roles-served",
4646
"note": "ADR-0085 semantic role: objectui plugin-detail detectStatusField drives the record:path stepper; string names the lifecycle field, false suppresses heuristic stage detection."
4747
},
48+
"editMode": {
49+
"status": "live",
50+
"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)."
51+
},
4852
"fieldGroups": {
4953
"status": "live",
5054
"proof": "packages/qa/dogfood/test/semantic-roles.dogfood.test.ts#semantic-roles-served",

packages/spec/liveness/state-counts.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ for both corollaries.
2727

2828
| Type | live | exp | dead | planned | classified |
2929
|---|---|---|---|---|---|
30-
| `object` | 50 | 0 | 0 | 1 | 51 |
30+
| `object` | 51 | 0 | 0 | 1 | 52 |
3131
| `field` | 89 | 0 | 0 | 2 | 91 |
3232
| `flow` | 34 | 0 | 6 | 0 | 40 |
3333
| `action` | 42 | 0 | 2 | 2 | 46 |
@@ -58,4 +58,4 @@ for both corollaries.
5858
| `capability` | 12 | 0 | 0 | 0 | 12 |
5959
| `qa` | 4 | 0 | 5 | 0 | 9 |
6060
| `manifest` | 22 | 0 | 21 | 0 | 43 |
61-
| **total** | **822** | **5** | **76** | **10** | **913** |
61+
| **total** | **823** | **5** | **76** | **10** | **914** |

packages/spec/src/data/object.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1632,6 +1632,43 @@ describe('ObjectSchema semantic roles (ADR-0085)', () => {
16321632
});
16331633
});
16341634

1635+
describe('ObjectSchema editMode (#11408 — declared by maintainer ruling, #10144 family)', () => {
1636+
it('accepts both enum values through the strict parse and carries them on the output', () => {
1637+
// Full parse green (not merely "no unrecognized_keys"): the ruling adopts
1638+
// the key as authored surface, so a legal document must parse end to end.
1639+
const modal = ObjectSchema.parse({ name: 'task', fields: {}, editMode: 'modal' });
1640+
expect(modal.editMode).toBe('modal');
1641+
const page = ObjectSchema.parse({ name: 'task', fields: {}, editMode: 'page' });
1642+
expect(page.editMode).toBe('page');
1643+
});
1644+
1645+
it('stays optional: an object without editMode parses and the output carries no value', () => {
1646+
const parsed = ObjectSchema.parse({ name: 'task', fields: {} });
1647+
expect(parsed.editMode).toBeUndefined();
1648+
});
1649+
1650+
it('rejects a value outside the enum, as a VALUE error located at editMode — not unrecognized_keys', () => {
1651+
// Before #11408 the failure mode was `unrecognized_keys` at the top level
1652+
// (the key itself was unknown). Declaring the key moves the judgment to
1653+
// the VALUE: a bad spelling must now fail as an enum error at the
1654+
// `editMode` path, proving the key is recognised and its value contract
1655+
// is enforced.
1656+
for (const bad of ['drawer', 'inline', true, 3] as const) {
1657+
const result = ObjectSchema.safeParse({ name: 'task', fields: {}, editMode: bad });
1658+
expect(result.success).toBe(false);
1659+
if (!result.success) {
1660+
expect(result.error.issues[0].code).not.toBe('unrecognized_keys');
1661+
expect(result.error.issues[0].path).toEqual(['editMode']);
1662+
}
1663+
}
1664+
});
1665+
1666+
it('is accepted by the authoring path (create()) as well as parse()', () => {
1667+
const created = ObjectSchema.create({ name: 'task', fields: {}, editMode: 'page' });
1668+
expect(created.editMode).toBe('page');
1669+
});
1670+
});
1671+
16351672
describe('ObjectSchema.fieldGroups', () => {
16361673
it('should accept an object without fieldGroups (fully optional)', () => {
16371674
const result = ObjectSchema.safeParse({

packages/spec/src/data/object.zod.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2073,6 +2073,24 @@ const ObjectSchemaBase = strictObject(
20732073
*/
20742074
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.'),
20752075

2076+
/**
2077+
* Cross-renderer EDIT-INTERACTION intent: whether a record of this object is
2078+
* edited in a modal dialog over the current view (`'modal'`) or on a
2079+
* dedicated full-page edit route (`'page'`). Like the other hints in this
2080+
* block it is an intent about the record, not pixel styling — any renderer
2081+
* can honour it. Absent = the renderer chooses its own default (objectui's
2082+
* shipped runtime treats anything but `'page'` as modal:
2083+
* `recordFormNavigation.ts` branches on `editMode !== 'page'`, and
2084+
* `AppContent`'s `handleEdit` dispatcher routes on it).
2085+
*
2086+
* Declared here by the #11408 maintainer ruling (the measured residue of the
2087+
* #10144 declare-or-rule-out census): objectui had published the key to
2088+
* authors (CHANGELOG + live runtime read) while this strict parse rejected
2089+
* it. objectui's `ObjectSchemaClientExtensions.editMode` mirror retires in a
2090+
* release-gated follow-up and the spec derivation carries it from then on.
2091+
*/
2092+
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)."),
2093+
20762094
/**
20772095
* Built-in List Views
20782096
*

0 commit comments

Comments
 (0)