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/28199-record-path-stage-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@object-ui/plugin-detail': patch
'@object-ui/i18n': patch
---

feat(plugin-detail): `record:path` with `allowStageChange: true` lets the reader press a stage to move the record there

Clause-②: yes
Metadata-protocol: none

`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.

- **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.
- **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.
- **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.
- **Copy.** Seven `detail.pathStageChange*` keys, in all ten language packs.
2 changes: 1 addition & 1 deletion content/docs/ui/components/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ regenerates from the renderer registry. Regenerate it with
| [`record:highlights`](/docs/ui/components/record#record-highlights) | `record` | 7 | [Record](/docs/ui/components/record) |
| [`record:history`](/docs/ui/components/record#record-history) | `record` | 3 | [Record](/docs/ui/components/record) |
| [`record:line_items`](/docs/ui/components/record#record-line_items) | `record` | 16 | [Record](/docs/ui/components/record) |
| [`record:path`](/docs/ui/components/record#record-path) | `record` | 2 | [Record](/docs/ui/components/record) |
| [`record:path`](/docs/ui/components/record#record-path) | `record` | 3 | [Record](/docs/ui/components/record) |
| [`record:quick_actions`](/docs/ui/components/record#record-quick_actions) | `record` | 9 | [Record](/docs/ui/components/record) |
| [`record:reference_rail`](/docs/ui/components/record#record-reference_rail) | `record` | 1 | [Record](/docs/ui/components/record) |
| [`record:related_list`](/docs/ui/components/record#record-related_list) | `record` | 16 | [Record](/docs/ui/components/record) |
Expand Down
1 change: 1 addition & 0 deletions content/docs/ui/components/record.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ Namespace `record`.
| :--- | :--- | :--- | :--- |
| `statusField` | `string` | yes | Field representing the current status/stage |
| `stages` | `array` of `object` | | Explicit stage definitions [\{ value, label \}] (else derived from field metadata) |
| `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. |

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

Expand Down
4 changes: 2 additions & 2 deletions scripts/sdui-manifest.record.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
],
"source": "built-tree",
"generator": "scripts/gen-sdui-manifest-node.mjs",
"generatedAt": "2026-10-29",
"sha256": "d55576794739e7893f7ffdfeadfe8a0ebdd480469e95f431dc7bc80565f4e229",
"generatedAt": "2026-10-31",
"sha256": "5030c05123d51da87188ea9a0569aeafee106fa612e251765adb254e84bb7144",
"components": 106,
"inTree": {
"modulesRoot": "ui/apps/console",
Expand Down
5 changes: 5 additions & 0 deletions sdui.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1805,6 +1805,11 @@
"type": "array",
"of": "object",
"description": "Explicit stage definitions [{ value, label }] (else derived from field metadata)"
},
{
"name": "allowStageChange",
"type": "boolean",
"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."
}
]
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1663,12 +1663,6 @@ 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
7 changes: 7 additions & 0 deletions ui/packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1464,6 +1464,13 @@ const ar = {
pathStageLostCurrent: '{{stage}}، خاسرة، المرحلة الحالية',
pathStageLostUpcoming: '{{stage}}، خاسرة، لم يتم الوصول إليها',
pathStageWonUpcoming: '{{stage}}، المرحلة الهدف، لم يتم الوصول إليها',
pathStageChangeAction: 'الانتقال إلى {{stage}}',
pathStageChangeTitle: 'تغيير المرحلة؟',
pathStageChangeDescription: 'هل تريد نقل هذا السجل إلى {{stage}}؟',
pathStageChangeConfirm: 'نقل',
pathStageChangeConflict: 'قام شخص آخر بتغيير هذا السجل، ويتم الآن عرض أحدث إصدار. اضغط على المرحلة مرة أخرى إذا كان النقل لا يزال مطلوبًا.',
pathStageChangeDenied: 'ليس لديك إذن لتغيير مرحلة هذا السجل.',
pathStageChangeFailed: 'تعذر تغيير المرحلة.',
linkCopied: "تم نسخ الرابط إلى الحافظة",
commentFailed: "لم يُنشر تعليقك. لم يُحفظ أي شيء — يُرجى المحاولة مرة أخرى.",
reactionFailed: "لم يُحفظ تفاعلك. يُرجى المحاولة مرة أخرى.",
Expand Down
7 changes: 7 additions & 0 deletions ui/packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1306,6 +1306,13 @@ const de = {
pathStageLostCurrent: '{{stage}}, verloren, aktuelle Phase',
pathStageLostUpcoming: '{{stage}}, verloren, nicht erreicht',
pathStageWonUpcoming: '{{stage}}, Zielphase, nicht erreicht',
pathStageChangeAction: 'Zu {{stage}} verschieben',
pathStageChangeTitle: 'Phase ändern?',
pathStageChangeDescription: 'Diesen Datensatz zu {{stage}} verschieben?',
pathStageChangeConfirm: 'Verschieben',
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.',
pathStageChangeDenied: 'Sie sind nicht berechtigt, die Phase dieses Datensatzes zu ändern.',
pathStageChangeFailed: 'Die Phase konnte nicht geändert werden.',
linkCopied: "Link in die Zwischenablage kopiert",
commentFailed: "Ihr Kommentar wurde nicht veröffentlicht. Es wurde nichts gespeichert – bitte versuchen Sie es erneut.",
reactionFailed: "Ihre Reaktion wurde nicht gespeichert. Bitte versuchen Sie es erneut.",
Expand Down
7 changes: 7 additions & 0 deletions ui/packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1433,6 +1433,13 @@ const en = {
pathStageLostCurrent: '{{stage}}, closed lost, current stage',
pathStageLostUpcoming: '{{stage}}, closed lost, not reached',
pathStageWonUpcoming: '{{stage}}, goal stage, not reached',
pathStageChangeAction: 'Move to {{stage}}',
pathStageChangeTitle: 'Change stage?',
pathStageChangeDescription: 'Move this record to {{stage}}?',
pathStageChangeConfirm: 'Move',
pathStageChangeConflict: 'Someone else changed this record, and the latest version is now shown. Press the stage again if the move still applies.',
pathStageChangeDenied: "You don't have permission to change this record's stage.",
pathStageChangeFailed: 'The stage could not be changed.',
linkCopied: 'Link copied to clipboard',
commentFailed: 'Your comment was not posted. Nothing was saved — please try again.',
reactionFailed: 'Your reaction was not saved. Please try again.',
Expand Down
7 changes: 7 additions & 0 deletions ui/packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1347,6 +1347,13 @@ const es = {
pathStageLostCurrent: '{{stage}}, perdida, etapa actual',
pathStageLostUpcoming: '{{stage}}, perdida, no alcanzada',
pathStageWonUpcoming: '{{stage}}, etapa objetivo, no alcanzada',
pathStageChangeAction: 'Mover a {{stage}}',
pathStageChangeTitle: '¿Cambiar de etapa?',
pathStageChangeDescription: '¿Mover este registro a {{stage}}?',
pathStageChangeConfirm: 'Mover',
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.',
pathStageChangeDenied: 'No tienes permiso para cambiar la etapa de este registro.',
pathStageChangeFailed: 'No se ha podido cambiar la etapa.',
linkCopied: "Enlace copiado al portapapeles",
commentFailed: "Tu comentario no se ha publicado. No se ha guardado nada; inténtalo de nuevo.",
reactionFailed: "Tu reacción no se ha guardado. Inténtalo de nuevo.",
Expand Down
7 changes: 7 additions & 0 deletions ui/packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1344,6 +1344,13 @@ const fr = {
pathStageLostCurrent: '{{stage}}, perdue, étape actuelle',
pathStageLostUpcoming: '{{stage}}, perdue, non atteinte',
pathStageWonUpcoming: '{{stage}}, étape objectif, non atteinte',
pathStageChangeAction: 'Passer à {{stage}}',
pathStageChangeTitle: "Changer d'étape ?",
pathStageChangeDescription: 'Faire passer cet enregistrement à {{stage}} ?',
pathStageChangeConfirm: 'Déplacer',
pathStageChangeConflict: "Quelqu'un d'autre a modifié cet enregistrement ; la dernière version est maintenant affichée. Cliquez à nouveau sur l'étape si le changement s'applique toujours.",
pathStageChangeDenied: "Vous n'avez pas l'autorisation de modifier l'étape de cet enregistrement.",
pathStageChangeFailed: "L'étape n'a pas pu être modifiée.",
linkCopied: "Lien copié dans le presse-papiers",
commentFailed: "Votre commentaire n'a pas été publié. Rien n'a été enregistré — veuillez réessayer.",
reactionFailed: "Votre réaction n'a pas été enregistrée. Veuillez réessayer.",
Expand Down
7 changes: 7 additions & 0 deletions ui/packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1358,6 +1358,13 @@ const ja = {
pathStageLostCurrent: '{{stage}}、失注、現在のステージ',
pathStageLostUpcoming: '{{stage}}、失注、未到達',
pathStageWonUpcoming: '{{stage}}、目標ステージ、未到達',
pathStageChangeAction: '{{stage}}に移動',
pathStageChangeTitle: 'ステージを変更しますか?',
pathStageChangeDescription: 'このレコードを{{stage}}に移動しますか?',
pathStageChangeConfirm: '移動',
pathStageChangeConflict: '他のユーザーがこのレコードを変更したため、最新の内容を表示しています。移動が必要な場合は、もう一度ステージを押してください。',
pathStageChangeDenied: 'このレコードのステージを変更する権限がありません。',
pathStageChangeFailed: 'ステージを変更できませんでした。',
linkCopied: "リンクをクリップボードにコピーしました",
commentFailed: "コメントを投稿できませんでした。何も保存されていません。もう一度お試しください。",
reactionFailed: "リアクションが保存されませんでした。もう一度お試しください。",
Expand Down
7 changes: 7 additions & 0 deletions ui/packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1306,6 +1306,13 @@ const ko = {
pathStageLostCurrent: '{{stage}}, 실패, 현재 단계',
pathStageLostUpcoming: '{{stage}}, 실패, 도달하지 않음',
pathStageWonUpcoming: '{{stage}}, 목표 단계, 도달하지 않음',
pathStageChangeAction: '{{stage}}(으)로 이동',
pathStageChangeTitle: '단계를 변경하시겠습니까?',
pathStageChangeDescription: '이 레코드를 {{stage}}(으)로 이동하시겠습니까?',
pathStageChangeConfirm: '이동',
pathStageChangeConflict: '다른 사용자가 이 레코드를 변경하여 최신 버전을 표시합니다. 이동이 여전히 필요하면 단계를 다시 누르세요.',
pathStageChangeDenied: '이 레코드의 단계를 변경할 권한이 없습니다.',
pathStageChangeFailed: '단계를 변경할 수 없습니다.',
linkCopied: "링크가 클립보드에 복사됨",
commentFailed: "댓글이 게시되지 않았습니다. 아무것도 저장되지 않았습니다. 다시 시도해 주세요.",
reactionFailed: "반응이 저장되지 않았습니다. 다시 시도해 주세요.",
Expand Down
7 changes: 7 additions & 0 deletions ui/packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1344,6 +1344,13 @@ const pt = {
pathStageLostCurrent: '{{stage}}, perdida, etapa atual',
pathStageLostUpcoming: '{{stage}}, perdida, não alcançada',
pathStageWonUpcoming: '{{stage}}, etapa objetivo, não alcançada',
pathStageChangeAction: 'Mover para {{stage}}',
pathStageChangeTitle: 'Alterar etapa?',
pathStageChangeDescription: 'Mover este registro para {{stage}}?',
pathStageChangeConfirm: 'Mover',
pathStageChangeConflict: 'Outra pessoa alterou este registro, e a versão mais recente está sendo exibida. Pressione a etapa novamente se a mudança ainda se aplicar.',
pathStageChangeDenied: 'Você não tem permissão para alterar a etapa deste registro.',
pathStageChangeFailed: 'Não foi possível alterar a etapa.',
linkCopied: "Link copiado para a área de transferência",
commentFailed: "Seu comentário não foi publicado. Nada foi salvo — tente novamente.",
reactionFailed: "Sua reação não foi salva. Tente novamente.",
Expand Down
7 changes: 7 additions & 0 deletions ui/packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1451,6 +1451,13 @@ const ru = {
pathStageLostCurrent: '{{stage}}, проигран, текущий этап',
pathStageLostUpcoming: '{{stage}}, проигран, не достигнут',
pathStageWonUpcoming: '{{stage}}, целевой этап, не достигнут',
pathStageChangeAction: 'Перевести на этап «{{stage}}»',
pathStageChangeTitle: 'Сменить этап?',
pathStageChangeDescription: 'Перевести эту запись на этап «{{stage}}»?',
pathStageChangeConfirm: 'Перевести',
pathStageChangeConflict: 'Запись изменил другой пользователь, теперь показана последняя версия. Нажмите на этап ещё раз, если перевод всё ещё нужен.',
pathStageChangeDenied: 'У вас нет прав на смену этапа этой записи.',
pathStageChangeFailed: 'Не удалось сменить этап.',
linkCopied: "Ссылка скопирована в буфер обмена",
commentFailed: "Комментарий не опубликован. Ничего не сохранено — попробуйте ещё раз.",
reactionFailed: "Реакция не сохранена. Попробуйте ещё раз.",
Expand Down
7 changes: 7 additions & 0 deletions ui/packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1185,6 +1185,13 @@ const zh = {
pathStageLostCurrent: '{{stage}},已失败,当前阶段',
pathStageLostUpcoming: '{{stage}},已失败,未到达',
pathStageWonUpcoming: '{{stage}},目标阶段,未到达',
pathStageChangeAction: '移至{{stage}}',
pathStageChangeTitle: '更改阶段?',
pathStageChangeDescription: '将此记录移至{{stage}}?',
pathStageChangeConfirm: '移动',
pathStageChangeConflict: '其他人已修改此记录,现已显示最新版本。如仍需移动,请再次点击该阶段。',
pathStageChangeDenied: '你没有更改此记录阶段的权限。',
pathStageChangeFailed: '无法更改阶段。',
linkCopied: '链接已复制到剪贴板',
commentFailed: '评论未发布,内容没有保存,请重试。',
reactionFailed: '表情回应未保存,请重试。',
Expand Down
6 changes: 5 additions & 1 deletion ui/packages/plugin-detail/src/InlineEditSaveBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,12 @@ function splitRefusal(
* Issue a partial-record update through whichever method the DataSource
* exposes. Mirrors the update/updateOne/patch fallback + `ifMatch` OCC token
* that `record:details` used for its per-field save.
*
* Exported for the package's other in-place writer, `record:path`'s stage
* press, so the record page has one update path; not re-exported from
* `src/index.tsx`.
*/
async function updateVia(
export async function updateVia(
ds: any,
objectName: string,
recordId: string | number,
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/plugin-detail/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,8 @@ ComponentRegistry.register('path', RecordPathRenderer, {
inputs: [
{ name: 'statusField', type: 'string', required: true, description: 'Field representing the current status/stage' },
{ name: 'stages', type: 'array', of: 'object', description: 'Explicit stage definitions [{ value, label }] (else derived from field metadata)' },
// objectstack#28061 — read by `useStageChange` (`renderers/recordPathStageChange.tsx`).
{ name: 'allowStageChange', type: 'boolean', 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.' },
],
});

Expand Down
Loading
Loading