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
16 changes: 16 additions & 0 deletions .changeset/28061-record-path-allow-stage-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/spec': minor
---

feat(spec): a `record:path` block may opt in to `allowStageChange`, so a stage can be pressed to move the record there

Clause-②: yes (widening)
Metadata-protocol: added (record:path allowStageChange)

A `record:path` block draws the status field's stages as a progress rail, and nothing on it can be pressed: moving a deal to the next stage means opening the edit form and changing the picklist.

- **Spec.** `RecordPathProps` (`ComponentPropsMap['record:path']`) declares an optional boolean `allowStageChange`. Off unless authored, with no schema default, so every existing path keeps its read-only look and parses exactly as before. Any value other than a boolean is refused at the key.
- **Meaning.** With the key on, each stage the reader may move the record to is a button, offered only to a reader who may edit `statusField`. A press asks for confirmation, then saves `statusField` through the same record update `record:details` uses, carrying the record's concurrency token, so the object's state machine validation and the field's edit rights decide the move like any other edit. A refused move (an illegal transition, a missing required field) is shown in place. Nothing new is judged on the server.
- **Renderer.** The console does not read the key yet; the path stays a readout until the `plugin-detail` path renderer honours it. The block's registry does not publish the input until then.

Nothing to do for existing pages. Once the console honours it, add `allowStageChange: true` to a `record:path` node's properties to let readers change the stage from the path.
1 change: 1 addition & 0 deletions content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1882,6 +1882,7 @@ Sort field and direction pair
| :--- | :--- | :--- | :--- |
| **statusField** | `string` | ✅ | Field name representing the current status/stage |
| **stages** | `{ value: string; label: string \| Record<string, string>; terminal?: Enum<'won' \| 'lost'> }[]` | optional | Explicit stage definitions (if not using field metadata) |
| **allowStageChange** | `boolean` | optional | Let the reader change the record's stage by pressing a stage on the path. Absent or `false`: the path is a read-only progress rail, as today. Each press asks for confirmation and saves `statusField` through the ordinary record update, so the object's state machine validation and the field's edit rights decide; stages appear pressable only to readers who may edit `statusField`, and a refused move is shown in place. |
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |

### Nested Shape: `RecordPathProps.stages[number]`
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -1196,6 +1196,7 @@
"ui/RecordLineItemsProps:sort",
"ui/RecordLineItemsProps:title",
"ui/RecordLineItemsProps:totalField",
"ui/RecordPathProps:allowStageChange",
"ui/RecordPathProps:aria",
"ui/RecordPathProps:stages",
"ui/RecordPathProps:statusField",
Expand Down
32 changes: 32 additions & 0 deletions packages/spec/src/ui/component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2602,6 +2602,38 @@ describe('RecordPathProps stages[].terminal — declared because the path render
});
});

// #28061 — `allowStageChange` is the opt-in that turns the path's stages into
// presses requesting that status. Off unless authored, so the readout keeps its
// non-interactive look.
describe('RecordPathProps allowStageChange — opt-in, off unless authored', () => {
it('accepts the opt-in and keeps the authored value', () => {
const result = RecordPathProps.safeParse({ statusField: 'stage', allowStageChange: true });
expect(result.success).toBe(true);
expect(result.data!.allowStageChange).toBe(true);
});

it('leaves an existing path unchanged: no default is written', () => {
const authored = {
statusField: 'status',
stages: [
{ value: 'todo', label: 'To Do' },
{ value: 'done', label: 'Done', terminal: 'won' as const },
],
};
const result = RecordPathProps.parse(authored);
expect(result).toEqual(authored);
expect('allowStageChange' in result).toBe(false);
});

it('refuses a non-boolean at the key, with the code and path', () => {
const result = RecordPathProps.safeParse({ statusField: 'stage', allowStageChange: 'yes' });
expect(result.success).toBe(false);
expect(result.error!.issues).toEqual([
expect.objectContaining({ code: 'invalid_type', path: ['allowStageChange'] }),
]);
});
});

// ---------------------------------------------------------------------------
// Enhanced RecordActivityProps (Unified Timeline)
// ---------------------------------------------------------------------------
Expand Down
18 changes: 18 additions & 0 deletions packages/spec/src/ui/component.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2082,6 +2082,24 @@ export const RecordPathProps = strictObject({
*/
terminal: z.enum(['won', 'lost']).optional().describe('Mark this stage a terminus and its kind — overrides the renderer\'s value/label token heuristic'),
})).optional().describe('Explicit stage definitions (if not using field metadata)'),
/**
* Opt in to changing the record's stage from the path itself. Absent or
* `false` is the read-only progress rail, as today, so every existing path
* keeps its non-interactive look. No schema default, for the
* `RecordChatterProps` reason — a default would write `false` onto every
* parsed component and turn "the author said nothing" into "the author
* asked for it".
*
* The spec declares the opt-in and its meaning only; nothing new is judged
* on the server. With the key on, each stage the reader may move the record
* to is a button, offered only when the reader may edit `statusField`. A
* press asks for confirmation, then writes `statusField` through the same
* record update `record:details` uses, carrying the record's concurrency
* token, so the object's `state_machine` validation (ADR-0020) and the
* field's edit rights judge the move like any other edit; a refusal (an
* illegal transition, a missing required field) is shown in place.
*/
allowStageChange: z.boolean().optional().describe('Let the reader change the record\'s stage by pressing a stage on the path. Absent or `false`: the path is a read-only progress rail, as today. Each press asks for confirmation and saves `statusField` through the ordinary record update, so the object\'s state machine validation and the field\'s edit rights decide; stages appear pressable only to readers who may edit `statusField`, and a refused move is shown in place.'),
/** ARIA accessibility */
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1663,6 +1663,12 @@ const UNPUBLISHED_EXEMPTIONS: Record<string, string> = {
'Unpublished by measurement (objectui#13047): read by nothing on a page `list-view` node. The node draws, queries and hands its view the same thing with and without it — the view\'s own `name` is its identity in metadata, which an embedded list has no use for.',
'list-view.sharing':
'Unpublished by measurement (objectui#13047): `sharing.type` draws a Share button with no click handler, and the sharing config is enforced nowhere in the renderer. Publishing it would advertise a dead control (objectui#13047\'s report).',
// Spec-first opt-in (objectstack#28061): the spec half landed before the
// console half, so the path renderer does not read it yet. The console card
// that makes `record-path.tsx` honour it publishes the input, and the stale
// check above then demands this entry's deletion in the same change.
'record:path.allowStageChange':
'Unpublished until the renderer reads it (objectstack#28061): the spec declares the opt-in that turns path stages into presses requesting that status, and `record-path.tsx` still draws a read-only rail. Publishing it now would advertise a control that does nothing.',
};

/**
Expand Down
Loading