Skip to content

Commit be25e2d

Browse files
feat(plugin-detail): record:path stage press moves the record when allowStageChange is set (#28303)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoApKXpwrRwrv5gg6ftdt8KE)_ Fixes #28199 Refs #28061 Clause-②: yes Metadata-protocol: none ## 维护者速读 - 改了什么:`record:path` 设了 `allowStageChange: true` 后,有权编辑状态字段的用户可以点击阶段(当前阶段除外),确认后把记录移到该阶段;没设这个键或无编辑权的用户,看到的仍是原来的只读进度条。 - 为什么改:HotCRM 销售测试反馈要能在路径上直接推进阶段(#27355 第 1 行);spec 已在 #28198 声明了这个键,但控制台一直没读它,作者写了也没效果。 - 风险与回滚:只在作者显式开启时生效;写入走记录详情行内编辑同一条更新路径并带 `ifMatch` 并发令牌,合法与否由服务端状态机/校验/权限判定,前端不做第二套判定。回滚即 revert 本 PR,未开启的页面零影响。 - 先看:`ui/packages/plugin-detail/src/renderers/recordPathStageChange.tsx`(谁能点、写入、拒绝文案)、`ui/packages/plugin-detail/src/renderers/record-path.tsx`(阶段按钮与就地拒绝区)、`ui/packages/plugin-detail/src/index.tsx`(注册表新增输入)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-detail/src/renderers/__tests__/record-path.stageChange-28199.test.tsx`,预期 14 个用例全过。 - 需要你做的:无,席位落地。 ## What changes - **`record:path` honours `allowStageChange`.** With `allowStageChange: true`, each stage other than the current one carries a real `button` (accessible name "Move to STAGE") inside its existing `listitem`, on both the desktop and the phone row. A press opens a confirmation (`AlertDialog`); confirming writes `{ [statusField]: value }` and re-reads the record. Without the key, or with `false`, the DOM is the readout it was. - **Who can press** (`useStageChange`, new `renderers/recordPathStageChange.tsx`): the record is stored and its data source can update; the record page's edit session allows editing the record (`useInlineEdit().canEdit`, the page host's record-level verdict); the status field is not read-only, computed or a system field; the caller's `recordEdit` field grant holds (`resolveFieldAffordance`). `RecordPathRenderer` now mounts `RecordFieldAccessForRecord` around the body when the key is on, so that grant is asked of this stored row, as `record:details` and `record:highlights` ask it. While the edit session holds an inline draft, the path is a readout again. - **One update path.** The write goes through `updateVia` (now exported from `InlineEditSaveBar.tsx`, not from the package index), the function the inline-edit save bar uses, with the record's `updated_at` as `ifMatch`. - **Refusals in place** (`stageRefusalLines`), under the rails, once for both rows: a `CONCURRENT_UPDATE` 409 gets a fixed line and the record is re-read; a producer-marked `userMessage` verbatim; a field-scoped refusal (`VALIDATION_FAILED` `fields[]`, the shape an illegal `state_machine` transition and a missing required field arrive in) one line per entry, an entry about another field prefixed with its label; a permission refusal the delegated-scope reason or a fixed line; anything else the server message or a fixed line. The next press clears it. - **Published.** `allowStageChange` joins the block's registry `inputs` (boolean, renderer-accurate description), so `sdui.manifest.json`, `scripts/sdui-manifest.record.json` and the generated component reference (`content/docs/ui/components/record.mdx`, `index.mdx`) are regenerated, and the `UNPUBLISHED_EXEMPTIONS` entry `record:path.allowStageChange` is deleted from `registry-inputs-spec-parity.test.ts`. - **Copy.** Seven `detail.pathStageChange*` keys in all ten packs and in `DETAIL_DEFAULT_TRANSLATIONS`. - **Changeset.** `.changeset/28199-record-path-stage-change.md`, `@object-ui/plugin-detail` and `@object-ui/i18n` patch. ## Why The spec half (#28198) declared `RecordPathProps.allowStageChange`, and the console registry carried it as unpublished because nothing read it. Authors who set it got the read-only rail. This is the console half that #28061 names. ## Tests `ui/packages/plugin-detail/src/renderers/__tests__/record-path.stageChange-28199.test.tsx`, 14 cases: - the press writes: buttons on every non-current stage in both rows; confirm calls `update('crm_quote', 'q1', { status: 'in_review' }, { ifMatch: VERSION })` and `refresh`; cancel writes nothing; - a refusal renders in place: an illegal `state_machine` transition (the server sentence alone), a required field elsewhere (label-prefixed), a 403 (fixed line, not the raw text), a stale token (fixed line plus a re-read), and the next press clears it; - the readout stays: the key absent (control, for a reader who could edit), `false`, the page's edit session refusing the record, the field edit grant denied, a read-only status field, an inline draft in progress. Ablations (one-shot, each through `scripts/ablation-replace.mjs`, which proved the mutation landed and restored `git diff HEAD` empty; the subject resolves to source through the vitest alias, so no rebuild applies): | Mutation in `recordPathStageChange.tsx` | Red | | :--- | :--- | | `ifMatch` token dropped from the update | the write case (1 of 14) | | `allowStageChange` removed from the verdict | both opt-in controls (2 of 14) | | `!inline.editing` removed | the inline-draft case (1 of 14) | | `resolveFieldAffordance(...)` removed | the field-grant case (1 of 14) | | `setRefusal(...)` removed | all five refusal cases (5 of 14) | ## Acceptance notes - **"Reachable" means "drawn and not current."** The path does not read the object's `state_machine` transitions to hide stages: that would be a second evaluator of a rule the server owns. Every drawn stage the record is not on is pressable, and an illegal move comes back as the server's refusal, shown in place. Stages a record's own columns rule out (option `visibleWhen`) are already not drawn. - **Inline draft.** While the record's inline edit holds a draft the stages are not pressable, so a stage write never races the draft's Save. Said here because the card did not ask for it. - **Concurrency.** A 409 is not routed to `ConcurrentUpdateDialog`'s overwrite: a stage move is one field, and the re-read record plus a second press is the whole recovery. - **File surface.** Beyond the claim's surface, the diff carries the generated half of a registry change that root `AGENTS.md` requires ("A registry change has a second half"): `sdui.manifest.json`, `scripts/sdui-manifest.record.json`, and the generated reference pages `content/docs/ui/components/record.mdx` and `index.mdx` (`node scripts/gen-sdui-reference.mjs`, gated by `check:sdui-reference`). No hand edit in any of them. - **Clause-②: yes**, measured from the diff: the block's published `inputs` gain a key (the manifest and the component reference grow by one input). The claim recorded `no` as the seat's expectation; the dispatch order says publishing the input counts. - `ui/AGENTS.md` #2 (package README and docs): the docs page for this input is the generated component reference (`content/docs/ui/components/record.mdx`), regenerated here. `plugin-detail`'s README covers `record:details` / `record:highlights` / `record:quick_actions` / `record:related_list` binding and says nothing about `record:path` today; it is outside the claimed surface and left untouched — the seat may want a README line. ## Verification Every reading below is on head `2837cf76` unless it says otherwise. <details><summary>Tests (local, under the shared verify lock)</summary> - `pnpm --dir ui exec vitest run packages/i18n/ packages/app-shell/src/__tests__/defaults-maps-mirror-en-pack.test.tsx apps/console/src/__tests__/registry-inputs-spec-parity.test.ts packages/plugin-detail/` on `bc74bc54` → Test Files 442 passed, 1 skipped (443); Tests 4842 passed, 31 skipped. The only later commit types two object-definition reads in the new module. - `pnpm --dir ui exec vitest run packages/plugin-detail/src/renderers/__tests__/record-path.stageChange-28199.test.tsx` → 14 passed on `2837cf76`. - `pnpm --filter @object-ui/plugin-detail run type-check` and `pnpm --filter @object-ui/i18n run type-check` → exit 0 (full declaration build of the closure). - `eslint` on the changed `plugin-detail` files → 0 errors; the new module adds no warning. </details> <details><summary>Derived gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` → 76 families; every one run with stdin closed and its exit code recorded; `--ran` reconciliation: 76 derived, 76 run, 0 NOT-MEASURED, 0 UNRUN. - On the first run (head `bc74bc54`) `check:skill-examples` answered exit 3 (PREREQUISITE NOT MET: no `packages/spec` declarations) and exit 0 after a declaration build; on `2837cf76` all 76 answered exit 0 in one pass. - `node scripts/check-sdui-manifest.mjs` → exit 0 (present and intact, recorded by the in-tree producer). - `pnpm check:new-line-citations` (ui) → 0 new citations. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` untouched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (Changed files linted locally; the repo-wide run is CI's.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the maintainer brief above is Chinese by rule). --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibyyvCaZXvGAh38AKjD)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7eda096 commit be25e2d

22 files changed

Lines changed: 691 additions & 40 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/plugin-detail': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(plugin-detail): `record:path` with `allowStageChange: true` lets the reader press a stage to move the record there
7+
8+
Clause-②: yes
9+
Metadata-protocol: none
10+
11+
`RecordPathProps.allowStageChange` was declared in `@objectstack/spec` but read by nothing: the path stayed a read-only progress rail. The renderer now honours it, and the block's registry `inputs` publish it, so it appears in `sdui.manifest.json` and the component reference.
12+
13+
- **Who can press.** With `allowStageChange: true`, each stage other than the current one is a button, for a reader who may edit `statusField` on this record: the record page's edit session allows editing the record, the field is not read-only or computed, and the caller's field edit grant holds, asked of the stored row. Everyone else, and every path without the key or with `false`, keeps the readout exactly as before. While the record's inline edit holds unsaved changes the path is a readout too.
14+
- **The write.** A press asks for confirmation, then saves `{ statusField: value }` through the same record update the inline-edit save bar uses, with the record's `updated_at` as the `ifMatch` concurrency token, and re-reads the record.
15+
- **Refusals.** The server decides which moves are legal: the object's `state_machine` rule (ADR-0020), required fields and edit rights. A refused move is shown under the path: the server's own sentence for each refused field (a field other than the status field is named by its label), a fixed line for a permission refusal, and on a stale concurrency token a line saying the record changed, after the record is re-read.
16+
- **Copy.** Seven `detail.pathStageChange*` keys, in all ten language packs.

‎content/docs/ui/components/index.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ regenerates from the renderer registry. Regenerate it with
122122
| [`record:highlights`](/docs/ui/components/record#record-highlights) | `record` | 7 | [Record](/docs/ui/components/record) |
123123
| [`record:history`](/docs/ui/components/record#record-history) | `record` | 3 | [Record](/docs/ui/components/record) |
124124
| [`record:line_items`](/docs/ui/components/record#record-line_items) | `record` | 16 | [Record](/docs/ui/components/record) |
125-
| [`record:path`](/docs/ui/components/record#record-path) | `record` | 2 | [Record](/docs/ui/components/record) |
125+
| [`record:path`](/docs/ui/components/record#record-path) | `record` | 3 | [Record](/docs/ui/components/record) |
126126
| [`record:quick_actions`](/docs/ui/components/record#record-quick_actions) | `record` | 9 | [Record](/docs/ui/components/record) |
127127
| [`record:reference_rail`](/docs/ui/components/record#record-reference_rail) | `record` | 1 | [Record](/docs/ui/components/record) |
128128
| [`record:related_list`](/docs/ui/components/record#record-related_list) | `record` | 16 | [Record](/docs/ui/components/record) |

‎content/docs/ui/components/record.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ Namespace `record`.
132132
| :--- | :--- | :--- | :--- |
133133
| `statusField` | `string` | yes | Field representing the current status/stage |
134134
| `stages` | `array` of `object` | | Explicit stage definitions [\{ value, label \}] (else derived from field metadata) |
135+
| `allowStageChange` | `boolean` | | Let the reader change the record's stage by pressing a stage on the path (default: off, a read-only progress rail). Each press asks for confirmation, then saves `statusField` through the record's ordinary update, guarded by the record's version, so the object's state machine and validation rules decide; a refused move is shown under the path. Stages are pressable only for a reader who may edit `statusField` on this record, and not while the record's inline edit holds unsaved changes. |
135136

136137
## `record:quick_actions` [#record-quick_actions]
137138

‎scripts/sdui-manifest.record.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
],
1414
"source": "built-tree",
1515
"generator": "scripts/gen-sdui-manifest-node.mjs",
16-
"generatedAt": "2026-10-29",
17-
"sha256": "d55576794739e7893f7ffdfeadfe8a0ebdd480469e95f431dc7bc80565f4e229",
16+
"generatedAt": "2026-10-31",
17+
"sha256": "5030c05123d51da87188ea9a0569aeafee106fa612e251765adb254e84bb7144",
1818
"components": 106,
1919
"inTree": {
2020
"modulesRoot": "ui/apps/console",

‎sdui.manifest.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1805,6 +1805,11 @@
18051805
"type": "array",
18061806
"of": "object",
18071807
"description": "Explicit stage definitions [{ value, label }] (else derived from field metadata)"
1808+
},
1809+
{
1810+
"name": "allowStageChange",
1811+
"type": "boolean",
1812+
"description": "Let the reader change the record's stage by pressing a stage on the path (default: off, a read-only progress rail). Each press asks for confirmation, then saves `statusField` through the record's ordinary update, guarded by the record's version, so the object's state machine and validation rules decide; a refused move is shown under the path. Stages are pressable only for a reader who may edit `statusField` on this record, and not while the record's inline edit holds unsaved changes."
18081813
}
18091814
]
18101815
},

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

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1663,12 +1663,6 @@ 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.',
16721666
};
16731667

16741668
/**

‎ui/packages/i18n/src/locales/ar.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1464,6 +1464,13 @@ const ar = {
14641464
pathStageLostCurrent: '{{stage}}، خاسرة، المرحلة الحالية',
14651465
pathStageLostUpcoming: '{{stage}}، خاسرة، لم يتم الوصول إليها',
14661466
pathStageWonUpcoming: '{{stage}}، المرحلة الهدف، لم يتم الوصول إليها',
1467+
pathStageChangeAction: 'الانتقال إلى {{stage}}',
1468+
pathStageChangeTitle: 'تغيير المرحلة؟',
1469+
pathStageChangeDescription: 'هل تريد نقل هذا السجل إلى {{stage}}؟',
1470+
pathStageChangeConfirm: 'نقل',
1471+
pathStageChangeConflict: 'قام شخص آخر بتغيير هذا السجل، ويتم الآن عرض أحدث إصدار. اضغط على المرحلة مرة أخرى إذا كان النقل لا يزال مطلوبًا.',
1472+
pathStageChangeDenied: 'ليس لديك إذن لتغيير مرحلة هذا السجل.',
1473+
pathStageChangeFailed: 'تعذر تغيير المرحلة.',
14671474
linkCopied: "تم نسخ الرابط إلى الحافظة",
14681475
commentFailed: "لم يُنشر تعليقك. لم يُحفظ أي شيء — يُرجى المحاولة مرة أخرى.",
14691476
reactionFailed: "لم يُحفظ تفاعلك. يُرجى المحاولة مرة أخرى.",

‎ui/packages/i18n/src/locales/de.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1306,6 +1306,13 @@ const de = {
13061306
pathStageLostCurrent: '{{stage}}, verloren, aktuelle Phase',
13071307
pathStageLostUpcoming: '{{stage}}, verloren, nicht erreicht',
13081308
pathStageWonUpcoming: '{{stage}}, Zielphase, nicht erreicht',
1309+
pathStageChangeAction: 'Zu {{stage}} verschieben',
1310+
pathStageChangeTitle: 'Phase ändern?',
1311+
pathStageChangeDescription: 'Diesen Datensatz zu {{stage}} verschieben?',
1312+
pathStageChangeConfirm: 'Verschieben',
1313+
pathStageChangeConflict: 'Jemand anderes hat diesen Datensatz geändert; die aktuelle Version wird jetzt angezeigt. Klicken Sie erneut auf die Phase, wenn die Verschiebung weiterhin gilt.',
1314+
pathStageChangeDenied: 'Sie sind nicht berechtigt, die Phase dieses Datensatzes zu ändern.',
1315+
pathStageChangeFailed: 'Die Phase konnte nicht geändert werden.',
13091316
linkCopied: "Link in die Zwischenablage kopiert",
13101317
commentFailed: "Ihr Kommentar wurde nicht veröffentlicht. Es wurde nichts gespeichert – bitte versuchen Sie es erneut.",
13111318
reactionFailed: "Ihre Reaktion wurde nicht gespeichert. Bitte versuchen Sie es erneut.",

‎ui/packages/i18n/src/locales/en.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1433,6 +1433,13 @@ const en = {
14331433
pathStageLostCurrent: '{{stage}}, closed lost, current stage',
14341434
pathStageLostUpcoming: '{{stage}}, closed lost, not reached',
14351435
pathStageWonUpcoming: '{{stage}}, goal stage, not reached',
1436+
pathStageChangeAction: 'Move to {{stage}}',
1437+
pathStageChangeTitle: 'Change stage?',
1438+
pathStageChangeDescription: 'Move this record to {{stage}}?',
1439+
pathStageChangeConfirm: 'Move',
1440+
pathStageChangeConflict: 'Someone else changed this record, and the latest version is now shown. Press the stage again if the move still applies.',
1441+
pathStageChangeDenied: "You don't have permission to change this record's stage.",
1442+
pathStageChangeFailed: 'The stage could not be changed.',
14361443
linkCopied: 'Link copied to clipboard',
14371444
commentFailed: 'Your comment was not posted. Nothing was saved — please try again.',
14381445
reactionFailed: 'Your reaction was not saved. Please try again.',

‎ui/packages/i18n/src/locales/es.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1347,6 +1347,13 @@ const es = {
13471347
pathStageLostCurrent: '{{stage}}, perdida, etapa actual',
13481348
pathStageLostUpcoming: '{{stage}}, perdida, no alcanzada',
13491349
pathStageWonUpcoming: '{{stage}}, etapa objetivo, no alcanzada',
1350+
pathStageChangeAction: 'Mover a {{stage}}',
1351+
pathStageChangeTitle: '¿Cambiar de etapa?',
1352+
pathStageChangeDescription: '¿Mover este registro a {{stage}}?',
1353+
pathStageChangeConfirm: 'Mover',
1354+
pathStageChangeConflict: 'Otra persona ha modificado este registro y ahora se muestra la versión más reciente. Vuelve a pulsar la etapa si el cambio sigue siendo válido.',
1355+
pathStageChangeDenied: 'No tienes permiso para cambiar la etapa de este registro.',
1356+
pathStageChangeFailed: 'No se ha podido cambiar la etapa.',
13501357
linkCopied: "Enlace copiado al portapapeles",
13511358
commentFailed: "Tu comentario no se ha publicado. No se ha guardado nada; inténtalo de nuevo.",
13521359
reactionFailed: "Tu reacción no se ha guardado. Inténtalo de nuevo.",

0 commit comments

Comments
 (0)