Skip to content

Commit fb4e0bb

Browse files
feat(spec): a record:path block may opt in to allowStageChange, so a stage can be pressed to move the record there (#28198)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoAkUMdnw6MHYbw4FotfMxXw)_ Fixes #28061 Clause-②: yes (widening) Metadata-protocol: added Skills-docs: none — `skills/objectstack-ui/rules/pages.md` teaches `record:path` as `statusField` + `stages` and every taught use stays true; teaching `allowStageChange` waits for the console half that makes it do something, so the skill line belongs to that follow-up. ## 维护者速读 - 改了什么:`record:path` 区块的 spec(`RecordPathProps`)新增一个可选布尔键 `allowStageChange`,作者打开后,路径上的阶段可以被点击来请求把记录改到该状态;不写或写 `false` 时仍是今天的只读进度条。 - 为什么改:HotCRM 销售测试反馈(#27355 第 1 行):想把商机推进到下一阶段只能打开编辑表单改下拉框。本 PR 只做 spec 半边,声明这个开关和它的含义;控制台渲染(`plugin-detail` 的 `record-path.tsx`)是另一张 `domain:ui` 卡。 - 风险与回滚:纯加宽,没有 schema 默认值,已有页面解析结果完全不变;服务端不改,状态机校验和字段编辑权限照旧由普通的记录更新判定。回滚即 revert 本 PR。 - 先看:`packages/spec/src/ui/component.zod.ts` 的 `allowStageChange`(describe 写明含义)、`packages/spec/src/ui/component.test.ts` 新增的三条测试、`ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts` 新增的一条 `UNPUBLISHED_EXEMPTIONS` 豁免。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/ui/component.test.ts -t allowStageChange`,预期 3 条全部通过。 - 需要你做的:无,席位落地(等契约复审记录)。 ## What changes - `RecordPathProps` (`ComponentPropsMap['record:path']`) declares `allowStageChange: z.boolean().optional()`, with no schema default (the `RecordChatterProps` reason: a default would write `false` onto every parsed component). Absent or `false` is the read-only progress rail, as today. The `.describe()` and docblock state the console contract from the card: 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 confirms, then saves `statusField` through the same record update `record:details` uses, with the record's concurrency token, so the object's `state_machine` validation (ADR-0020) and field edit rights decide; a refusal is shown in place. No server change. - Regenerated: `packages/spec/authorable-surface/ui.json` (+1 row, `ui/RecordPathProps:allowStageChange`) and `content/docs/references/ui/component.mdx` (+1 row). - `ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`: one `UNPUBLISHED_EXEMPTIONS` entry, `'record:path.allowStageChange'`, citing objectstack#28061. Without it the gate's reverse direction (a key the spec declares must be published in the block's registry `inputs`) reds Test UI, because `ui/` consumes this PR's spec through `workspace:^`. The key is not published because the path renderer does not read it yet, the file's own bar for an entry. When the console half publishes the input, the file's stale check demands the entry's deletion in that change. Seat ruling A (this card's thread) widened the file surface by exactly this entry; nothing else under `ui/` changes. - Changeset `28061-record-path-allow-stage-change.md`: `@objectstack/spec` minor, both declaration lines. **Console follow-up for the seat to file (`domain:ui`):** `ui/packages/plugin-detail/src/renderers/record-path.tsx` honours `allowStageChange` (stage buttons gated by the reader's edit verdict on `statusField`, confirm, write through the `record:details` update path with the concurrency token, refusal shown in place), the `record:path` registration publishes the input, the `UNPUBLISHED_EXEMPTIONS` entry above is deleted, and `skills/objectstack-ui/rules/pages.md` teaches the key. ## Tests `packages/spec/src/ui/component.test.ts`, new block "RecordPathProps allowStageChange — opt-in, off unless authored": - `accepts the opt-in and keeps the authored value`: `safeParse` succeeds and `allowStageChange` is `true`. - `leaves an existing path unchanged: no default is written` (control): the showcase-shaped `statusField` + `stages` input parses to an object equal to the input, with no `allowStageChange` key. - `refuses a non-boolean at the key, with the code and path`: `'yes'` gives exactly one issue, `code: 'invalid_type'`, `path: ['allowStageChange']`. <details><summary>Local runs</summary> - `pnpm --filter @objectstack/spec typecheck` and `pnpm --filter @objectstack/spec exec vitest run` on `d9b26f02`, under the verify lock: VERDICT command-exit 0; Test Files 843 passed (843), Tests 23972 passed, 1 todo. - `pnpm --dir ui exec vitest run apps/console/src/__tests__/registry-inputs-spec-parity.test.ts` on `d9b26f02`: Test Files 1 passed, Tests 261 passed (261). - Control for the exemption entry: with the `'record:path.allowStageChange'` entry removed (`grep -c` 0 after the edit), the same file fails exactly one case, "record:path publishes every top-level key its spec props schema declares" (`expected [ 'allowStageChange' ] to deeply equal []`), 1 failed and 260 passed. Restored with `git checkout HEAD --`; `git diff HEAD` empty. - After merging `origin/main` (4 commits, `packages/spec` changes in the automation domain only), on `526055d6`: workspace build exit 0, `pnpm --filter @objectstack/spec check:generated` "All 14 generated artifacts are up to date", `src/ui/component.test.ts` 353/353 and the parity file 261/261. - Dogfood not run: no `!` in the title and no `(narrowing)` arm. </details> ## Acceptance notes - The key name is the card's "name for review"; the contract review judges it. It is named for the act (changing the stage), not the widget. - The order's file surface was `packages/spec` only. The ui parity gate made that impossible without a red Test UI; reported to the seat before any code and ruled A (one exemption entry). - `content/docs/ui/components/record.mdx` is generated from the registry `inputs`, so it does not list the key until the console half publishes it. ## Verification - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 106 commands on `d9b26f02`. All 106 ran with stdin from /dev/null, exit codes captured before any pipe: 106 exited 0. Commands 60 to 106 first ran while a dts-less build of the console's dependencies was rewriting `dist/`; they were re-run after a full declaration build and all 47 exited 0 again. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran FILE`: "106 derived famil(ies) accounted for — 106 run, 0 NOT-MEASURED". - The gate sweep was taken on `d9b26f02`; the merge with `origin/main` (`526055d6`) brought in 4 commits, none touching `packages/spec/src/ui` or `ui/`. - `test:repo` not run locally (CI's Test Core). - CI on this PR: not yet read; the seat reads the check-runs. --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibxNQzFQH8nBAaoSUkx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e26cf9a commit fb4e0bb

6 files changed

Lines changed: 74 additions & 0 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): a `record:path` block may opt in to `allowStageChange`, so a stage can be pressed to move the record there
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: added (record:path allowStageChange)
9+
10+
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.
11+
12+
- **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.
13+
- **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.
14+
- **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.
15+
16+
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.

‎content/docs/references/ui/component.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1882,6 +1882,7 @@ Sort field and direction pair
18821882
| :--- | :--- | :--- | :--- |
18831883
| **statusField** | `string` | ✅ | Field name representing the current status/stage |
18841884
| **stages** | `{ value: string; label: string \| Record<string, string>; terminal?: Enum<'won' \| 'lost'> }[]` | optional | Explicit stage definitions (if not using field metadata) |
1885+
| **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. |
18851886
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
18861887

18871888
### Nested Shape: `RecordPathProps.stages[number]`

‎packages/spec/authorable-surface/ui.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1197,6 +1197,7 @@
11971197
"ui/RecordLineItemsProps:sort",
11981198
"ui/RecordLineItemsProps:title",
11991199
"ui/RecordLineItemsProps:totalField",
1200+
"ui/RecordPathProps:allowStageChange",
12001201
"ui/RecordPathProps:aria",
12011202
"ui/RecordPathProps:stages",
12021203
"ui/RecordPathProps:statusField",

‎packages/spec/src/ui/component.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2602,6 +2602,38 @@ describe('RecordPathProps stages[].terminal — declared because the path render
26022602
});
26032603
});
26042604

2605+
// #28061 — `allowStageChange` is the opt-in that turns the path's stages into
2606+
// presses requesting that status. Off unless authored, so the readout keeps its
2607+
// non-interactive look.
2608+
describe('RecordPathProps allowStageChange — opt-in, off unless authored', () => {
2609+
it('accepts the opt-in and keeps the authored value', () => {
2610+
const result = RecordPathProps.safeParse({ statusField: 'stage', allowStageChange: true });
2611+
expect(result.success).toBe(true);
2612+
expect(result.data!.allowStageChange).toBe(true);
2613+
});
2614+
2615+
it('leaves an existing path unchanged: no default is written', () => {
2616+
const authored = {
2617+
statusField: 'status',
2618+
stages: [
2619+
{ value: 'todo', label: 'To Do' },
2620+
{ value: 'done', label: 'Done', terminal: 'won' as const },
2621+
],
2622+
};
2623+
const result = RecordPathProps.parse(authored);
2624+
expect(result).toEqual(authored);
2625+
expect('allowStageChange' in result).toBe(false);
2626+
});
2627+
2628+
it('refuses a non-boolean at the key, with the code and path', () => {
2629+
const result = RecordPathProps.safeParse({ statusField: 'stage', allowStageChange: 'yes' });
2630+
expect(result.success).toBe(false);
2631+
expect(result.error!.issues).toEqual([
2632+
expect.objectContaining({ code: 'invalid_type', path: ['allowStageChange'] }),
2633+
]);
2634+
});
2635+
});
2636+
26052637
// ---------------------------------------------------------------------------
26062638
// Enhanced RecordActivityProps (Unified Timeline)
26072639
// ---------------------------------------------------------------------------

‎packages/spec/src/ui/component.zod.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2082,6 +2082,24 @@ export const RecordPathProps = strictObject({
20822082
*/
20832083
terminal: z.enum(['won', 'lost']).optional().describe('Mark this stage a terminus and its kind — overrides the renderer\'s value/label token heuristic'),
20842084
})).optional().describe('Explicit stage definitions (if not using field metadata)'),
2085+
/**
2086+
* Opt in to changing the record's stage from the path itself. Absent or
2087+
* `false` is the read-only progress rail, as today, so every existing path
2088+
* keeps its non-interactive look. No schema default, for the
2089+
* `RecordChatterProps` reason — a default would write `false` onto every
2090+
* parsed component and turn "the author said nothing" into "the author
2091+
* asked for it".
2092+
*
2093+
* The spec declares the opt-in and its meaning only; nothing new is judged
2094+
* on the server. With the key on, each stage the reader may move the record
2095+
* to is a button, offered only when the reader may edit `statusField`. A
2096+
* press asks for confirmation, then writes `statusField` through the same
2097+
* record update `record:details` uses, carrying the record's concurrency
2098+
* token, so the object's `state_machine` validation (ADR-0020) and the
2099+
* field's edit rights judge the move like any other edit; a refusal (an
2100+
* illegal transition, a missing required field) is shown in place.
2101+
*/
2102+
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.'),
20852103
/** ARIA accessibility */
20862104
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
20872105
});

‎ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1663,6 +1663,12 @@ const UNPUBLISHED_EXEMPTIONS: Record<string, string> = {
16631663
'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.',
16641664
'list-view.sharing':
16651665
'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).',
1666+
// Spec-first opt-in (objectstack#28061): the spec half landed before the
1667+
// console half, so the path renderer does not read it yet. The console card
1668+
// that makes `record-path.tsx` honour it publishes the input, and the stale
1669+
// check above then demands this entry's deletion in the same change.
1670+
'record:path.allowStageChange':
1671+
'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.',
16661672
};
16671673

16681674
/**

0 commit comments

Comments
 (0)