Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/object-editmode-declared.md
Original file line numberDiff line numberDiff line change
@@ -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.
1 change: 1 addition & 0 deletions content/docs/references/data/object.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<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) |
| **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 |
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/data.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/spec/liveness/object.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions packages/spec/liveness/state-counts.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 |
Expand DownExpand Up@@ -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** |
37 changes: 37 additions & 0 deletions packages/spec/src/data/object.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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({
Expand Down
18 changes: 18 additions & 0 deletions packages/spec/src/data/object.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
*
Expand Down
Loading