Skip to content

Commit 5fa9375

Browse files
feat(plugin-detail): the default record page lets an editor advance the stage from the path (#30221)
Fixes #30075 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:没有自定义页面的默认记录页上,有权编辑阶段字段的用户可以直接点击阶段路径推进记录(先弹确认框);状态机不允许到达的阶段不再显示为可点击。 - 为什么改:推进商机阶段是 CRM 最高频的更新,原来要「编辑 → 改阶段 → 更新」三步;裁决选方案 A,不新增任何元数据键。 - 风险与回滚:风险是误点,已有二次确认;保存仍走普通记录更新,校验与状态机由服务端判定,被拒时就地显示原因。回滚即还原 `buildDefaultPageSchema.ts` 里那一个 `allowStageChange: true`。 - 先看:`ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts`(默认页写入开关的那一行);`ui/packages/plugin-detail/src/renderers/recordPathStageChange.tsx`(`admits`,按状态机收窄可点阶段);`ui/packages/plugin-detail/src/renderers/record-path.tsx`(每个阶段是否渲染为按钮)。 - 两分钟验证:`pnpm dev:crm` 后以管理员打开任一商机记录页,点击下一个阶段,应弹出「Move this record to …?」确认框,确认后阶段前移;以只读用户打开同一页,阶段路径不可点击。 - 需要你做的:无,席位落地。 ## What changes The landing point is the producer of the default record page, `buildDefaultHighlights` in `ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts`: the `record:path` node it emits now carries `allowStageChange: true`, the existing spec key (`RecordPathProps.allowStageChange`). No key is added and nothing under `packages/**` is edited. The console's record page (`RecordDetailView`) renders that synthesized page whenever no page is assigned, so this is where "a default record page" is built. Who may press is unchanged and decided entirely by the existing opt-in (`useStageChange`): the page's edit session may edit the record, the stage field is not read-only, computed or a system field, and the caller's field edit grant holds for the stored row. Everyone else, and an editor whose inline edit holds unsaved changes, keeps the read-only path. Every press opens a confirmation, then saves through the record's ordinary update with the record version (`updateVia`, the inline save bar's path), so the server's validation and `state_machine` rule judge it; a refusal is shown under the path. One change to the renderer, made because the default page now puts the press in front of every app: a stage the stage field's blocking `state_machine` cannot reach from the stored value is drawn as a readout, not a button. It reads the same `stateMachineNextValues` the record form, the grid's inline editor and the board use, so the path offers exactly the moves the edit dialog offers. A field without such a rule offers every stage, and the server still judges each write. This applies to every path with the opt-in, authored ones included. A declared page keeps its own setting: an assigned page writes its own `record:path` node and never reaches the synthesizer, and an authored `highlights` slot replaces the default strip, so its `true`, `false` or absent key is kept as written (pinned below). `record-path.inertReadout.test.tsx` encodes the 2026-08-23 "separate card" ruling. Its assertions are unchanged: every mount there is a node without the opt-in, which is still the readout. Only its docblock changes, to say that the separate card landed as the opt-in and that the default page now carries it, so that file's pins now describe authored pages that leave the key out or set it `false`. ## Tests New pins, as two top-level `describe` blocks in the existing shared harness `record-path.stageChange-28199.test.tsx`. The synthesized node is mounted as the JSON document `buildDefaultPageSchema` emits, through the real `SchemaRenderer`: 1. The synthesized page writes `allowStageChange: true` on its path node. 2. An editor gets a press on each other stage; nothing is written before the confirmation; confirming writes `{ status: 'in_review' }` with the record version and re-reads. 3. A reader the page may not let edit the record keeps the read-only path; so does a reader without the field edit grant. 4. An authored `highlights` slot keeps `allowStageChange: true`, `false`, and the key left out, each rendered accordingly. 5. With a blocking `state_machine` (`draft` to `in_review` or `declined` only), `Accepted` is a readout and not a button. A warning-severity machine does not narrow the press (control). 6. A move the server still refuses for a required field elsewhere shows `Amount: is required ...` under the path; the current stage and the record are unchanged. The card listed the fill-in path (a stage move that needs another required field) as not measured. Measured: the press refuses in place and names the missing field by label (pin 6, client half). The server half is the existing objectql pin `validate-update-stored-row.test.ts` (b): an update whose patch turns a `requiredWhen` true without supplying the field answers `VALIDATION_FAILED` with `fields: [{ field: 'note', code: 'required' }]` and leaves the row unchanged (18/18 green on this branch). There is no in-place fill-in; the reader opens the edit dialog to supply the field. Nothing worse than #28303's refusal in place was found. Ablation (each through `scripts/ablation-replace.mjs`, which checks the mutation landed and restores to the HEAD blob; the subjects resolve to source, so no rebuild is involved): - Removing `allowStageChange: true` from the synthesizer fails 5 of 24 in the harness (pins 1, 2, 5 and its control, 6). Restored, blob equals HEAD. - Removing `&& stageChange.admits(o.stage.value)` from the renderer fails exactly pin 5 (1 of 24). Restored, blob equals HEAD. A first attempt with the anchor ending in a bare `;` was refused by the tool (its replacement count could not move) and changed nothing; the anchor was respelled. <details><summary>Test runs</summary> - `vitest run --isolate packages/plugin-detail/` (whole package, isolated as the CI shard runs it): Test Files 369 passed, 1 skipped (370); Tests 3270 passed, 18 skipped. This ran before `origin/main` was merged in; the merge changed nothing in these files. - `record-path.stageChange-28199.test.tsx` verbose: 24/24, including the 10 new `#30075` cases. - `record-path*` + `synth/`: 30 files, 320 tests passed. - `packages/objectql` `validate-update-stored-row.test.ts`: 18/18. </details> ## Acceptance notes - The registry input description in `ui/packages/plugin-detail/src/index.tsx` and the spec `.describe()` still say the key's default is off. Both describe the key, and the key's default has not changed: the default page sets it explicitly. They are left as they are, so `sdui.manifest.json` and the spec docs are not regenerated. - Studio's page preview (`PagePreview`) renders the same synthesized page. Its preview host mounts no edit session, so the path stays a readout there. ## Verification Local gate families derived by `node scripts/pm/dispatch-gates.mjs --commands` on this branch. Results and the commit they ran against are listed in the os-dev report comment on #30075. CI runs the full set. --- _Generated by [Claude Code](https://claude.ai/code/session_01QSeKYpFBMr917PmVkG9up7)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QSeKYpFBMr917PmVkG9up7 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bff5ba2 commit 5fa9375

6 files changed

Lines changed: 236 additions & 18 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/plugin-detail': patch
3+
---
4+
5+
feat(plugin-detail): the default record page lets an editor move a record to another stage by pressing it on the stage path
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
**Visible behaviour change on a released page.** The record page the console synthesizes for an object with a stage field (no authored page) used to draw its stage path as a read-only progress rail, so moving a deal to its next stage took 编辑 → change the stage → 更新. That page's `record:path` now carries `allowStageChange: true`, the existing opt-in (no new metadata key):
11+
12+
- **Who can press.** Unchanged from the opt-in: a reader the record page lets edit this record, on a stage field that is not read-only or computed, whose field edit grant holds for the stored row. Everyone else keeps the read-only path, and so does an editor while the record's inline edit holds unsaved changes.
13+
- **Every press asks for confirmation**, then saves the stage through the record's ordinary update with the record's version, so the object's validation rules and `state_machine` decide. A refused move is shown under the path, naming any other field the move needs (for example a field made required by the new stage); nothing is written.
14+
- **Only reachable stages are offered.** On every page with the opt-in, a stage the stage field's blocking `state_machine` cannot reach from the stored value is drawn as a readout, not a button, the same moves the record form, the grid's inline editor and the board offer. A field with no such rule offers every stage, and the server still judges each move.
15+
- **Authored pages keep their own setting.** A page, or an authored `highlights` slot, that declares its own `record:path` keeps whatever it declares: `true`, `false`, or the key left out (a read-only rail).
16+
17+
To keep a default record page's path read-only, assign the object an authored page (or a `highlights` slot) whose `record:path` leaves `allowStageChange` out or sets it `false`.

‎ui/packages/plugin-detail/src/renderers/__tests__/record-path.inertReadout.test.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@
2626
* markup stops claiming to be a control; a real click-to-advance stage control
2727
* is a separate card with its own appetite, NOT folded in here.
2828
*
29+
* That separate card landed as the `allowStageChange` opt-in
30+
* (objectstack#28061), and a later ruling (objectstack#30075, option A) has
31+
* the synthesized DEFAULT record page write the opt-in on its path, so for an
32+
* editor that page's path is a real control (pinned in
33+
* `record-path.stageChange-28199.test.tsx`). What this file pins is unchanged
34+
* and still binds: a `record:path` node that does not carry the opt-in — an
35+
* authored page that leaves the key out or sets it `false` — is the readout
36+
* below, and must not look like a control. Every mount here is such a node.
37+
*
2938
* ── What each group is for ────────────────────────────────────────────────
3039
*
3140
* Group A is the CONTROL — what must not move. It also establishes

‎ui/packages/plugin-detail/src/renderers/__tests__/record-path.stageChange-28199.test.tsx‎

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@
1111
* a server refusal is shown in place; a reader who may not edit the status
1212
* field — and every path that does not opt in — keeps the readout.
1313
*
14+
* The last two groups pin objectstack#30075 (ruling: option A): the
15+
* synthesized default record page's path carries the opt-in, mounted here as
16+
* the JSON document `buildDefaultPageSchema` emits, through the real
17+
* `SchemaRenderer`; an authored `highlights` slot keeps its declared value;
18+
* and a stage the field's blocking `state_machine` cannot reach from the
19+
* stored value is never offered as a press.
20+
*
1421
* The subject is imported relatively and `@object-ui/*` resolves to source
1522
* through the root vitest alias table, so nothing here reads a `dist/`.
1623
*/
@@ -19,7 +26,7 @@ import * as React from 'react';
1926
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
2027
import { render, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react';
2128
import '@testing-library/jest-dom';
22-
import { InlineEditProvider, RecordContextProvider, useInlineEdit } from '@object-ui/react';
29+
import { InlineEditProvider, RecordContextProvider, SchemaRenderer, useInlineEdit } from '@object-ui/react';
2330

2431
const { permsStub, perms } = vi.hoisted(() => {
2532
const perms: { isLoaded: boolean; writeDenied: string[] } = { isLoaded: true, writeDenied: [] };
@@ -49,6 +56,9 @@ vi.mock('@object-ui/permissions', async (importOriginal) => {
4956
});
5057

5158
import { RecordPathRenderer } from '../record-path';
59+
import { buildDefaultPageSchema } from '../../synth/buildDefaultPageSchema';
60+
// Registers `record:path` for the `SchemaRenderer` mounts below.
61+
import '../../index';
5262

5363
const STAGES = [
5464
{ value: 'draft', label: 'Draft' },
@@ -272,3 +282,151 @@ describe('record:path allowStageChange — who sees the readout', () => {
272282
await waitFor(() => noButtons(container));
273283
});
274284
});
285+
286+
/** The object as a host binds it: a stage picklist the default page derives its path from. */
287+
const QUOTE_DEF = {
288+
name: 'crm_quote',
289+
fields: {
290+
name: { type: 'text', label: 'Name' },
291+
status: {
292+
type: 'select',
293+
label: 'Status',
294+
options: STAGES.map(({ value, label }) => ({ value, label })),
295+
},
296+
amount: { type: 'currency', label: 'Amount' },
297+
},
298+
stageField: 'status',
299+
};
300+
301+
/** A blocking machine: from draft only in_review (or declined); never straight to accepted. */
302+
const MACHINE = {
303+
type: 'state_machine',
304+
name: 'quote_flow',
305+
field: 'status',
306+
message: 'A quote cannot skip review.',
307+
transitions: { draft: ['in_review', 'declined'], in_review: ['accepted', 'declined'] },
308+
};
309+
310+
/** Every `record:path` node among a synthesized page's top-level components. */
311+
const pathNodesOf = (page: any): any[] =>
312+
page.regions[0].components.filter((c: any) => c?.type === 'record:path');
313+
314+
function mountNode(node: unknown, opts: Omit<MountOptions, 'allowStageChange'> = {}) {
315+
const dataSource = opts.dataSource ?? makeDataSource();
316+
const refresh = opts.refresh ?? vi.fn(async () => {});
317+
const r = render(
318+
<RecordContextProvider
319+
objectName="crm_quote"
320+
recordId="q1"
321+
data={{ id: 'q1', status: 'draft', updated_at: VERSION }}
322+
objectSchema={opts.objectSchema ?? QUOTE_DEF}
323+
dataSource={dataSource as any}
324+
refresh={refresh}
325+
>
326+
<InlineEditProvider canEdit={opts.canEdit ?? true}>
327+
<SchemaRenderer schema={node as never} />
328+
</InlineEditProvider>
329+
</RecordContextProvider>,
330+
);
331+
return { ...r, dataSource, refresh };
332+
}
333+
334+
const buttonNames = (row: HTMLElement): (string | null)[] =>
335+
within(row).queryAllByRole('button').map((b) => b.getAttribute('aria-label'));
336+
337+
describe('#30075 the default record page turns click-to-advance on', () => {
338+
it('the synthesized page writes allowStageChange: true on its path node', () => {
339+
const [path] = pathNodesOf(buildDefaultPageSchema(QUOTE_DEF as never));
340+
expect(path.properties).toMatchObject({ statusField: 'status', allowStageChange: true });
341+
});
342+
343+
it('a reader who may edit the stage field gets a press, with a confirmation, through the record update', async () => {
344+
const [path] = pathNodesOf(buildDefaultPageSchema(QUOTE_DEF as never));
345+
const { container, dataSource, refresh } = mountNode(path);
346+
expect(rows(container)).toHaveLength(2);
347+
for (const row of rows(container)) {
348+
expect(buttonNames(row)).toEqual(['Move to In review', 'Move to Accepted', 'Move to Declined']);
349+
}
350+
fireEvent.click(within(rows(container)[0]).getByRole('button', { name: 'Move to In review' }));
351+
// Nothing is written before the reader confirms.
352+
const dialog = await screen.findByRole('alertdialog');
353+
expect(dataSource.update).not.toHaveBeenCalled();
354+
fireEvent.click(within(dialog).getByRole('button', { name: 'Move' }));
355+
await waitFor(() => expect(dataSource.update).toHaveBeenCalledTimes(1));
356+
expect(dataSource.update).toHaveBeenCalledWith('crm_quote', 'q1', { status: 'in_review' }, { ifMatch: VERSION });
357+
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1));
358+
});
359+
360+
it('a reader the page may not let edit this record keeps the read-only path', () => {
361+
const [path] = pathNodesOf(buildDefaultPageSchema(QUOTE_DEF as never));
362+
const { container } = mountNode(path, { canEdit: false });
363+
expect(rows(container)).toHaveLength(2);
364+
for (const row of rows(container)) expect(row.querySelectorAll('button,[tabindex]')).toHaveLength(0);
365+
});
366+
367+
it('a reader without the edit grant on the stage field keeps the read-only path', () => {
368+
perms.writeDenied = ['status'];
369+
const [path] = pathNodesOf(buildDefaultPageSchema(QUOTE_DEF as never));
370+
const { container } = mountNode(path);
371+
expect(rows(container)).toHaveLength(2);
372+
for (const row of rows(container)) expect(row.querySelectorAll('button,[tabindex]')).toHaveLength(0);
373+
});
374+
375+
it.each([true, false])('an authored highlights slot keeps its declared allowStageChange: %s', (declared) => {
376+
const authored = { type: 'record:path', properties: { statusField: 'status', stages: STAGES, allowStageChange: declared } };
377+
const page = buildDefaultPageSchema(QUOTE_DEF as never, { slots: { highlights: [authored] } } as never);
378+
const [path] = pathNodesOf(page);
379+
expect(path.properties.allowStageChange).toBe(declared);
380+
const { container } = mountNode(path);
381+
const names = buttonNames(rows(container)[0]);
382+
expect(names.length > 0).toBe(declared);
383+
});
384+
385+
it('an authored highlights slot that leaves the key out keeps the readout', () => {
386+
const authored = { type: 'record:path', properties: { statusField: 'status', stages: STAGES } };
387+
const page = buildDefaultPageSchema(QUOTE_DEF as never, { slots: { highlights: [authored] } } as never);
388+
const [path] = pathNodesOf(page);
389+
expect('allowStageChange' in path.properties).toBe(false);
390+
for (const row of rows(mountNode(path).container)) expect(buttonNames(row)).toEqual([]);
391+
});
392+
});
393+
394+
describe('#30075 the press offers only the moves the field\'s state machine admits', () => {
395+
const MACHINED = { ...QUOTE_DEF, validations: [MACHINE] };
396+
397+
it('a stage the machine cannot reach from the stored value is a readout, not a button', () => {
398+
const [path] = pathNodesOf(buildDefaultPageSchema(MACHINED as never));
399+
const { container } = mountNode(path, { objectSchema: MACHINED });
400+
for (const row of rows(container)) {
401+
expect(buttonNames(row)).toEqual(['Move to In review', 'Move to Declined']);
402+
// The unreachable stage is still drawn, as a plain listitem.
403+
expect(within(row).getByText('Accepted').closest('button')).toBeNull();
404+
}
405+
});
406+
407+
it('control: a warning-severity machine does not narrow the press (the server would not refuse)', () => {
408+
const warned = { ...QUOTE_DEF, validations: [{ ...MACHINE, severity: 'warning' }] };
409+
const [path] = pathNodesOf(buildDefaultPageSchema(warned as never));
410+
const { container } = mountNode(path, { objectSchema: warned });
411+
expect(buttonNames(rows(container)[0])).toEqual(['Move to In review', 'Move to Accepted', 'Move to Declined']);
412+
});
413+
414+
it('a move the server still refuses (here a required field elsewhere) is shown in place, nothing else changes', async () => {
415+
const refusal = Object.assign(new Error('VALIDATION_FAILED'), {
416+
code: 'VALIDATION_FAILED',
417+
fields: [{ field: 'amount', code: 'required', message: 'is required to send a quote for review' }],
418+
});
419+
const [path] = pathNodesOf(buildDefaultPageSchema(MACHINED as never));
420+
const { container, refresh } = mountNode(path, {
421+
objectSchema: MACHINED,
422+
dataSource: makeDataSource(async () => { throw refusal; }),
423+
});
424+
await pressAndConfirm(container, 'In review');
425+
const alert = await within(container).findByRole('alert');
426+
expect(alert).toHaveTextContent('Amount: is required to send a quote for review');
427+
expect(refresh).not.toHaveBeenCalled();
428+
for (const row of rows(container)) {
429+
expect(row.querySelector('[aria-current="step"]')).toHaveAttribute('aria-label', 'Draft, current stage');
430+
}
431+
});
432+
});

‎ui/packages/plugin-detail/src/renderers/record-path.tsx‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,15 @@
2626
* mouseup → click) left the record's status untouched. Users spent clicks
2727
* on it before concluding it was decoration.
2828
*
29-
* Unless the author opts in with `allowStageChange: true`, nothing on the
30-
* path writes, so the pixels must not promise a press. Click-to-advance is
31-
* that opt-in (objectstack#28061, `./recordPathStageChange`): only then, and
32-
* only for a reader who may edit `statusField` on this record, does each stage
33-
* other than the current one carry a real `<button>` that asks to move the
34-
* record there. Everyone else — and every page that does not opt in — gets the
35-
* readout below, unchanged.
29+
* Unless the node carries `allowStageChange: true`, nothing on the path
30+
* writes, so the pixels must not promise a press. Click-to-advance is that
31+
* opt-in (objectstack#28061, `./recordPathStageChange`): only then, and only
32+
* for a reader who may edit `statusField` on this record, does each stage
33+
* other than the current one — and that the field's state machine can reach —
34+
* carry a real `<button>` that asks to move the record there. The synthesized
35+
* default record page writes the opt-in on its path (objectstack#30075); an
36+
* authored page keeps what it declared. Everyone else — and every node that
37+
* does not opt in — gets the readout below, unchanged.
3638
*
3739
* So the presentation below is a PROGRESS RAIL: a thin decorative indicator
3840
* per stage with the label as plain text beneath it, which is the same
@@ -594,9 +596,10 @@ const RecordPathBody: React.FC<RecordPathRendererProps> = ({
594596
/** The label's full text as a `title`, for a row whose labels can be cut. */
595597
labelTitle?: string;
596598
}) => {
597-
// A stage is a press only behind the opt-in, and never the stage the
598-
// record already stands on: there is nowhere to move to.
599-
const press = stageChange.pressable && o.state !== 'current';
599+
// A stage is a press only behind the opt-in, never the stage the record
600+
// already stands on (there is nowhere to move to), and never a stage the
601+
// field's blocking state machine cannot reach from the stored value.
602+
const press = stageChange.pressable && o.state !== 'current' && stageChange.admits(o.stage.value);
600603
const face = (
601604
<>
602605
<span aria-hidden="true" data-stage-rail="" className={railClass(o.state, o.terminal)} />

‎ui/packages/plugin-detail/src/renderers/recordPathStageChange.tsx‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,18 @@
2525
* `updated_at` as the `ifMatch` concurrency token, and re-reads the record.
2626
*
2727
* Which moves are legal is the SERVER's call: the object's `state_machine`
28-
* rule (ADR-0020), required fields and edit rights are judged on the write, and
29-
* nothing here evaluates them. A refusal is shown in place, under the rail —
30-
* the server's own sentences first, a fixed line only when it sent none.
28+
* rule (ADR-0020), required fields and edit rights are judged on the write. A
29+
* refusal is shown in place, under the rail — the server's own sentences
30+
* first, a fixed line only when it sent none.
31+
*
32+
* What the path OFFERS follows the same machine the record form, the grid's
33+
* inline editor and the board read (`stateMachineNextValues`): a stage the
34+
* field's blocking `state_machine` rule cannot reach from the STORED value is
35+
* drawn as a readout, not a button, so the press never promises a move the
36+
* server is bound to refuse. The default record page turns this block's press
37+
* on (objectstack#30075), which is why an always-refused button would reach
38+
* every app with a machine on its stage field. A field without such a rule
39+
* offers every stage, and the server still judges each write.
3140
*/
3241

3342
import React from 'react';
@@ -41,7 +50,7 @@ import {
4150
AlertDialogHeader,
4251
AlertDialogTitle,
4352
} from '@object-ui/components';
44-
import { resolveFieldAffordance } from '@object-ui/core';
53+
import { resolveFieldAffordance, stateMachineNextValues } from '@object-ui/core';
4554
import { usePermissions } from '@object-ui/permissions';
4655
import {
4756
declaredUserMessage,
@@ -113,6 +122,12 @@ const canUpdate = (ds: unknown): boolean => {
113122
export interface StageChange {
114123
/** Whether this reader may press a stage of this record's path. */
115124
pressable: boolean;
125+
/**
126+
* Whether the field's blocking `state_machine` admits a move from the
127+
* stored value to `value` — `true` for every value when no such rule
128+
* governs the field. Only meaningful while `pressable`.
129+
*/
130+
admits: (value: unknown) => boolean;
116131
/** A write is in flight. */
117132
busy: boolean;
118133
/** The last refusal's lines, until the next press. */
@@ -152,6 +167,10 @@ export function useStageChange(allowStageChange: boolean, statusField: string |
152167
&& !NON_EDITABLE_SYSTEM_FIELDS.has(statusField)
153168
&& resolveFieldAffordance('recordEdit', perms, objectName, statusField);
154169

170+
const stored = statusField ? (ctx?.data as Record<string, unknown> | undefined)?.[statusField] : undefined;
171+
const reachable = statusField ? stateMachineNextValues(ctx?.objectSchema, statusField, stored) : null;
172+
const admits = (value: unknown): boolean => reachable === null || reachable.has(String(value));
173+
155174
const request = React.useCallback((stage: { value: unknown; label: string }) => {
156175
setRefusal(null);
157176
setTarget(stage);
@@ -166,7 +185,7 @@ export function useStageChange(allowStageChange: boolean, statusField: string |
166185
const confirm = async () => {
167186
const stage = target;
168187
setTarget(null);
169-
if (!stage || !pressable) return;
188+
if (!stage || !pressable || !admits(stage.value)) return;
170189
setBusy(true);
171190
const updatedAt = (ctx?.data as Record<string, unknown> | undefined)?.updated_at;
172191
const ifMatch = typeof updatedAt === 'string' ? updatedAt : undefined;
@@ -202,5 +221,5 @@ export function useStageChange(allowStageChange: boolean, statusField: string |
202221
</AlertDialog>
203222
);
204223

205-
return { pressable, busy, refusal, request, dialog };
224+
return { pressable, admits, busy, refusal, request, dialog };
206225
}

‎ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,18 @@ export function buildDefaultActions(
535535
*
536536
* Returns an array because the strip is conceptually one region with
537537
* two adjacent nodes.
538+
*
539+
* The default page's path turns click-to-advance on (`allowStageChange:
540+
* true`, maintainer ruling recorded on objectstack#30075, option A): moving a
541+
* record to its next stage is the record page's most frequent edit, and most
542+
* apps never author a page to opt in on. Nothing about WHO may press moves
543+
* here — `record:path`'s `useStageChange` still asks the page's edit session,
544+
* the field's read-only/computed markers and the caller's field edit grant,
545+
* offers only the moves the field's blocking `state_machine` admits, confirms
546+
* every press, and saves through the ordinary record update, so the server's
547+
* validation decides. A reader who may not edit the field keeps the readout.
548+
* An authored page — or an authored `highlights` slot — writes its own
549+
* `record:path` node and keeps whatever it declared; no new key is involved.
538550
*/
539551
export function buildDefaultHighlights(
540552
def: ObjectDefLike | undefined,
@@ -550,7 +562,7 @@ export function buildDefaultHighlights(
550562
out.push(componentNode('record:highlights', { fields: highlightFields }));
551563
}
552564
if (statusField && stages && stages.length > 0) {
553-
out.push(componentNode('record:path', { statusField, stages }));
565+
out.push(componentNode('record:path', { statusField, stages, allowStageChange: true }));
554566
}
555567
return out;
556568
}

0 commit comments

Comments
 (0)