From 94b7d871290cbf3abce00171e5cef5dfa4291e65 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 02:48:17 +0000 Subject: [PATCH 1/4] feat(plugin-detail): record:path stage press behind allowStageChange (wip) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyyvCaZXvGAh38AKjD --- ui/packages/i18n/src/locales/ar.ts | 7 + ui/packages/i18n/src/locales/de.ts | 7 + ui/packages/i18n/src/locales/en.ts | 7 + ui/packages/i18n/src/locales/es.ts | 7 + ui/packages/i18n/src/locales/fr.ts | 7 + ui/packages/i18n/src/locales/ja.ts | 7 + ui/packages/i18n/src/locales/ko.ts | 7 + ui/packages/i18n/src/locales/pt.ts | 7 + ui/packages/i18n/src/locales/ru.ts | 7 + ui/packages/i18n/src/locales/zh.ts | 7 + .../plugin-detail/src/InlineEditSaveBar.tsx | 6 +- .../src/renderers/record-path.tsx | 129 ++++++++--- .../src/renderers/recordPathStageChange.tsx | 200 ++++++++++++++++++ .../plugin-detail/src/useDetailTranslation.ts | 10 + 14 files changed, 384 insertions(+), 31 deletions(-) create mode 100644 ui/packages/plugin-detail/src/renderers/recordPathStageChange.tsx diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 2122b9dff6c..8cd3d07b026 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -1464,6 +1464,13 @@ const ar = { pathStageLostCurrent: '{{stage}}، خاسرة، المرحلة الحالية', pathStageLostUpcoming: '{{stage}}، خاسرة، لم يتم الوصول إليها', pathStageWonUpcoming: '{{stage}}، المرحلة الهدف، لم يتم الوصول إليها', + pathStageChangeAction: 'الانتقال إلى {{stage}}', + pathStageChangeTitle: 'تغيير المرحلة؟', + pathStageChangeDescription: 'هل تريد نقل هذا السجل إلى {{stage}}؟', + pathStageChangeConfirm: 'نقل', + pathStageChangeConflict: 'قام شخص آخر بتغيير هذا السجل، ويتم الآن عرض أحدث إصدار. اضغط على المرحلة مرة أخرى إذا كان النقل لا يزال مطلوبًا.', + pathStageChangeDenied: 'ليس لديك إذن لتغيير مرحلة هذا السجل.', + pathStageChangeFailed: 'تعذر تغيير المرحلة.', linkCopied: "تم نسخ الرابط إلى الحافظة", commentFailed: "لم يُنشر تعليقك. لم يُحفظ أي شيء — يُرجى المحاولة مرة أخرى.", reactionFailed: "لم يُحفظ تفاعلك. يُرجى المحاولة مرة أخرى.", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 8a9803d2ff4..65d3eb8bf62 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -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.", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index fc159b3d4fa..ba9df7d9bfa 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -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.', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index f68aff15eb1..8175f2b908f 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -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.", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 274c0725279..da2db0ba33d 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -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.", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 3e0c055e100..ee08c1b217d 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -1358,6 +1358,13 @@ const ja = { pathStageLostCurrent: '{{stage}}、失注、現在のステージ', pathStageLostUpcoming: '{{stage}}、失注、未到達', pathStageWonUpcoming: '{{stage}}、目標ステージ、未到達', + pathStageChangeAction: '{{stage}}に移動', + pathStageChangeTitle: 'ステージを変更しますか?', + pathStageChangeDescription: 'このレコードを{{stage}}に移動しますか?', + pathStageChangeConfirm: '移動', + pathStageChangeConflict: '他のユーザーがこのレコードを変更したため、最新の内容を表示しています。移動が必要な場合は、もう一度ステージを押してください。', + pathStageChangeDenied: 'このレコードのステージを変更する権限がありません。', + pathStageChangeFailed: 'ステージを変更できませんでした。', linkCopied: "リンクをクリップボードにコピーしました", commentFailed: "コメントを投稿できませんでした。何も保存されていません。もう一度お試しください。", reactionFailed: "リアクションが保存されませんでした。もう一度お試しください。", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index a9de46c5142..53c4e9a7cc6 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -1306,6 +1306,13 @@ const ko = { pathStageLostCurrent: '{{stage}}, 실패, 현재 단계', pathStageLostUpcoming: '{{stage}}, 실패, 도달하지 않음', pathStageWonUpcoming: '{{stage}}, 목표 단계, 도달하지 않음', + pathStageChangeAction: '{{stage}}(으)로 이동', + pathStageChangeTitle: '단계를 변경하시겠습니까?', + pathStageChangeDescription: '이 레코드를 {{stage}}(으)로 이동하시겠습니까?', + pathStageChangeConfirm: '이동', + pathStageChangeConflict: '다른 사용자가 이 레코드를 변경하여 최신 버전을 표시합니다. 이동이 여전히 필요하면 단계를 다시 누르세요.', + pathStageChangeDenied: '이 레코드의 단계를 변경할 권한이 없습니다.', + pathStageChangeFailed: '단계를 변경할 수 없습니다.', linkCopied: "링크가 클립보드에 복사됨", commentFailed: "댓글이 게시되지 않았습니다. 아무것도 저장되지 않았습니다. 다시 시도해 주세요.", reactionFailed: "반응이 저장되지 않았습니다. 다시 시도해 주세요.", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 1ba5dab8ea3..2321d102f3c 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -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.", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index b6928304408..c4db6281eb2 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -1451,6 +1451,13 @@ const ru = { pathStageLostCurrent: '{{stage}}, проигран, текущий этап', pathStageLostUpcoming: '{{stage}}, проигран, не достигнут', pathStageWonUpcoming: '{{stage}}, целевой этап, не достигнут', + pathStageChangeAction: 'Перевести на этап «{{stage}}»', + pathStageChangeTitle: 'Сменить этап?', + pathStageChangeDescription: 'Перевести эту запись на этап «{{stage}}»?', + pathStageChangeConfirm: 'Перевести', + pathStageChangeConflict: 'Запись изменил другой пользователь, теперь показана последняя версия. Нажмите на этап ещё раз, если перевод всё ещё нужен.', + pathStageChangeDenied: 'У вас нет прав на смену этапа этой записи.', + pathStageChangeFailed: 'Не удалось сменить этап.', linkCopied: "Ссылка скопирована в буфер обмена", commentFailed: "Комментарий не опубликован. Ничего не сохранено — попробуйте ещё раз.", reactionFailed: "Реакция не сохранена. Попробуйте ещё раз.", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 9ef5be1a8b8..b6dd85752b8 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1185,6 +1185,13 @@ const zh = { pathStageLostCurrent: '{{stage}},已失败,当前阶段', pathStageLostUpcoming: '{{stage}},已失败,未到达', pathStageWonUpcoming: '{{stage}},目标阶段,未到达', + pathStageChangeAction: '移至{{stage}}', + pathStageChangeTitle: '更改阶段?', + pathStageChangeDescription: '将此记录移至{{stage}}?', + pathStageChangeConfirm: '移动', + pathStageChangeConflict: '其他人已修改此记录,现已显示最新版本。如仍需移动,请再次点击该阶段。', + pathStageChangeDenied: '你没有更改此记录阶段的权限。', + pathStageChangeFailed: '无法更改阶段。', linkCopied: '链接已复制到剪贴板', commentFailed: '评论未发布,内容没有保存,请重试。', reactionFailed: '表情回应未保存,请重试。', diff --git a/ui/packages/plugin-detail/src/InlineEditSaveBar.tsx b/ui/packages/plugin-detail/src/InlineEditSaveBar.tsx index 2861661d390..056ff46224f 100644 --- a/ui/packages/plugin-detail/src/InlineEditSaveBar.tsx +++ b/ui/packages/plugin-detail/src/InlineEditSaveBar.tsx @@ -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, diff --git a/ui/packages/plugin-detail/src/renderers/record-path.tsx b/ui/packages/plugin-detail/src/renderers/record-path.tsx index 9df8ccd40cc..d023fbadd45 100644 --- a/ui/packages/plugin-detail/src/renderers/record-path.tsx +++ b/ui/packages/plugin-detail/src/renderers/record-path.tsx @@ -26,15 +26,13 @@ * mouseup → click) left the record's status untouched. Users spent clicks * on it before concluding it was decoration. * - * There is no write path to spend those clicks on: this renderer's only - * channel is `useRecordContext()`, whose value (`RecordContextValue` in - * `@object-ui/react`) exposes `data` / `refresh` / `headerSystemActions` / - * `onToggleFavorite` and NO record-field mutation. Editing goes through - * `record:details`' `` + `` - * (`dataSource.update(..., { ifMatch })`) or an action via - * `useActionEngine`; neither reaches here. Click-to-advance is a separate, - * approved-on-its-own-appetite feature — until it exists, the pixels must - * not promise it. + * Unless the author opts in with `allowStageChange: true`, nothing on the + * path writes, so the pixels must not promise a press. Click-to-advance is + * that opt-in (objectstack#28061, `./recordPathStageChange`): only then, and + * only for a reader who may edit `statusField` on this record, does each stage + * other than the current one carry a real ` + ) : ( + face )} - {o.stage.label} - - - ); + + ); + }; return (
@@ -684,6 +736,23 @@ export const RecordPathRenderer: React.FC = ({ }); })}
+ + {/* A refused move, in place under the rails — the server's own reason + (`stageRefusalLines`), shown once for both rows. */} + {stageChange.refusal && ( +
+
    + {stageChange.refusal.map((line, i) => ( +
  • {line}
  • + ))} +
+
+ )} + {stageChange.pressable && stageChange.dialog} ); }; diff --git a/ui/packages/plugin-detail/src/renderers/recordPathStageChange.tsx b/ui/packages/plugin-detail/src/renderers/recordPathStageChange.tsx new file mode 100644 index 00000000000..e5ba15f11e1 --- /dev/null +++ b/ui/packages/plugin-detail/src/renderers/recordPathStageChange.tsx @@ -0,0 +1,200 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * `record:path`'s stage press, behind the block's `allowStageChange` opt-in + * (`RecordPathProps.allowStageChange`, objectstack#28061). + * + * The path stays a readout unless three things hold: the author set + * `allowStageChange: true`, the record is stored and its data source can update + * it, and the reader may edit `statusField` on THIS record. The last is the + * question the record page's other in-place editors ask of a field + * (`HeaderHighlight`, `DetailSection`): the page's edit session may edit the + * record (`useInlineEdit().canEdit`, the page host's record-level verdict), the + * field is not read-only or computed, and the caller's `recordEdit` field grant + * holds — row-scoped when the block sits inside `RecordFieldAccessForRecord`, + * which `RecordPathRenderer` mounts for exactly this. While the edit session + * holds a draft the path is a readout again: that draft's Save is the record's + * one pending write. + * + * A press asks for confirmation, then writes `{ [statusField]: value }` through + * `updateVia`, the update path `InlineEditSaveBar` uses, with the record's + * `updated_at` as the `ifMatch` concurrency token, and re-reads the record. + * + * Which moves are legal is the SERVER's call: the object's `state_machine` + * rule (ADR-0020), required fields and edit rights are judged on the write, and + * nothing here evaluates them. A refusal is shown in place, under the rail — + * the server's own sentences first, a fixed line only when it sent none. + */ + +import React from 'react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@object-ui/components'; +import { resolveFieldAffordance } from '@object-ui/core'; +import { usePermissions } from '@object-ui/permissions'; +import { + declaredUserMessage, + delegationScopeMessage, + extractFieldErrors, + extractWriteErrorMessage, + isPermissionError, + useInlineEdit, + useRecordContext, + useSafeFieldLabel, +} from '@object-ui/react'; +import { pickLocalized, useObjectTranslation } from '@object-ui/i18n'; +import { isComputedFieldType } from '../fieldEnrichment'; +import { NON_EDITABLE_SYSTEM_FIELDS } from '../systemFields'; +import { updateVia } from '../InlineEditSaveBar'; +import { isConcurrentUpdateError } from '../ConcurrentUpdateDialog'; +import { useDetailTranslation } from '../useDetailTranslation'; + +/** The field a server refusal names when its rule declares no field. */ +const RECORD_LEVEL_FIELD = '_record'; + +type Translate = (key: string, opts?: Record) => string; + +/** + * The lines a refused stage write shows, in the order a reader needs them. + * + * 1. A 409 `CONCURRENT_UPDATE`: the record moved under the reader. The record + * is re-read, so the line says so rather than repeating a server string. + * 2. Text the producer marked for the end user (`userMessage`), verbatim. + * 3. A field-scoped refusal (`VALIDATION_FAILED` with `fields[]`, which is + * how an illegal `state_machine` transition and a missing required field + * arrive): one line per entry. An entry about `statusField` itself, or an + * object-level `_record` entry, reads alone; any other field is prefixed + * with its label, since the reader cannot see that field on the path. + * 4. A permission refusal: the delegated-admin scope reason when there is + * one, else a fixed line — a raw 403 string is never shown. + * 5. Anything else: the server's message, else a fixed line. + */ +export function stageRefusalLines( + err: unknown, + statusField: string, + labelOf: (field: string) => string, + t: Translate, +): string[] { + if (isConcurrentUpdateError(err)) return [t('detail.pathStageChangeConflict')]; + const declared = declaredUserMessage(err); + if (declared) return [declared]; + const fieldLines = (extractFieldErrors(err) ?? []) + .filter((e) => !!e.message) + .map((e) => + e.field === statusField || e.field === RECORD_LEVEL_FIELD ? e.message : `${labelOf(e.field)}: ${e.message}`, + ); + if (fieldLines.length > 0) return fieldLines; + if (isPermissionError(err)) return [delegationScopeMessage(err) ?? t('detail.pathStageChangeDenied')]; + return [extractWriteErrorMessage(err) ?? t('detail.pathStageChangeFailed')]; +} + +const canUpdate = (ds: unknown): boolean => { + const d = ds as Record | null | undefined; + return typeof d?.update === 'function' || typeof d?.updateOne === 'function' || typeof d?.patch === 'function'; +}; + +export interface StageChange { + /** Whether this reader may press a stage of this record's path. */ + pressable: boolean; + /** A write is in flight. */ + busy: boolean; + /** The last refusal's lines, until the next press. */ + refusal: string[] | null; + /** Ask to move the record to `stage` (opens the confirmation). */ + request: (stage: { value: unknown; label: string }) => void; + /** The confirmation dialog; render it once, beside the rails. */ + dialog: React.ReactNode; +} + +export function useStageChange(allowStageChange: boolean, statusField: string | undefined): StageChange { + const ctx = useRecordContext(); + const inline = useInlineEdit(); + const perms = usePermissions(); + const { t } = useDetailTranslation(); + const { fieldLabel } = useSafeFieldLabel(); + const { language } = useObjectTranslation(); + const [target, setTarget] = React.useState<{ value: unknown; label: string } | null>(null); + const [busy, setBusy] = React.useState(false); + const [refusal, setRefusal] = React.useState(null); + + const objectName = ctx?.objectName; + const recordId = ctx?.recordId; + const dataSource = ctx?.dataSource; + const fieldDef = statusField ? (ctx?.objectSchema as any)?.fields?.[statusField] : undefined; + const pressable = + allowStageChange + && !!statusField + && !!objectName + && recordId != null && recordId !== '' + && !!ctx?.data + && canUpdate(dataSource) + && !!inline?.canEdit + && !inline.editing + && fieldDef?.readonly !== true + && !isComputedFieldType(undefined, fieldDef?.type) + && !NON_EDITABLE_SYSTEM_FIELDS.has(statusField) + && resolveFieldAffordance('recordEdit', perms, objectName, statusField); + + const request = React.useCallback((stage: { value: unknown; label: string }) => { + setRefusal(null); + setTarget(stage); + }, []); + + const labelOf = (field: string): string => { + const declared = (ctx?.objectSchema as any)?.fields?.[field]?.label; + const fallback = declared == null ? field : String(pickLocalized(declared, language) ?? field); + return objectName ? fieldLabel(objectName, field, fallback) : fallback; + }; + + const confirm = async () => { + const stage = target; + setTarget(null); + if (!stage || !pressable) return; + setBusy(true); + const updatedAt = (ctx?.data as Record | undefined)?.updated_at; + const ifMatch = typeof updatedAt === 'string' ? updatedAt : undefined; + try { + await updateVia(dataSource, objectName!, recordId!, { [statusField!]: stage.value }, ifMatch ? { ifMatch } : undefined); + await ctx?.refresh?.(); + } catch (err) { + setRefusal(stageRefusalLines(err, statusField!, labelOf, t)); + // A stale token means the reader is looking at an old record: show the + // current one, so a second press is judged against what is stored. + if (isConcurrentUpdateError(err)) await ctx?.refresh?.(); + } finally { + setBusy(false); + } + }; + + const dialog = ( + { if (!open) setTarget(null); }}> + + + {t('detail.pathStageChangeTitle')} + + {t('detail.pathStageChangeDescription', { stage: target?.label ?? '' })} + + + + {t('detail.cancel')} + { void confirm(); }}> + {t('detail.pathStageChangeConfirm')} + + + + + ); + + return { pressable, busy, refusal, request, dialog }; +} diff --git a/ui/packages/plugin-detail/src/useDetailTranslation.ts b/ui/packages/plugin-detail/src/useDetailTranslation.ts index 9c090472682..cce8b867898 100644 --- a/ui/packages/plugin-detail/src/useDetailTranslation.ts +++ b/ui/packages/plugin-detail/src/useDetailTranslation.ts @@ -295,6 +295,16 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record = { // any other current/completed stage, so naming it apart would hand a screen // reader a distinction the screen does not make. 'detail.pathStageWonUpcoming': '{{stage}}, goal stage, not reached', + // `record:path` with `allowStageChange` (objectstack#28061): the stage + // button's accessible name, the confirmation, and the refusals the path + // shows in place when the server's answer carries no sentence of its own. + 'detail.pathStageChangeAction': 'Move to {{stage}}', + 'detail.pathStageChangeTitle': 'Change stage?', + 'detail.pathStageChangeDescription': 'Move this record to {{stage}}?', + 'detail.pathStageChangeConfirm': 'Move', + 'detail.pathStageChangeConflict': 'Someone else changed this record, and the latest version is now shown. Press the stage again if the move still applies.', + 'detail.pathStageChangeDenied': "You don't have permission to change this record's stage.", + 'detail.pathStageChangeFailed': 'The stage could not be changed.', // objectui#9050 — RelatedList's malformed-filter state. Byte-identical to // the `en` pack (`defaults-maps-mirror-en-pack`), and present HERE as well // because a provider-less host mounting `RelatedList` directly is exactly From 66dcb61996fa23b2bf471c556b1caf59ce122471 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 02:55:21 +0000 Subject: [PATCH 2/4] feat(plugin-detail): publish record:path allowStageChange, pin the stage press Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyyvCaZXvGAh38AKjD --- .../registry-inputs-spec-parity.test.ts | 6 - ui/packages/plugin-detail/src/index.tsx | 2 + .../record-path.stageChange-28199.test.tsx | 274 ++++++++++++++++++ 3 files changed, 276 insertions(+), 6 deletions(-) create mode 100644 ui/packages/plugin-detail/src/renderers/__tests__/record-path.stageChange-28199.test.tsx diff --git a/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 327b6794723..8d80c0d7d00 100644 --- a/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -1663,12 +1663,6 @@ const UNPUBLISHED_EXEMPTIONS: Record = { '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.', }; /** diff --git a/ui/packages/plugin-detail/src/index.tsx b/ui/packages/plugin-detail/src/index.tsx index 3f954431b83..83b247f0701 100644 --- a/ui/packages/plugin-detail/src/index.tsx +++ b/ui/packages/plugin-detail/src/index.tsx @@ -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.' }, ], }); diff --git a/ui/packages/plugin-detail/src/renderers/__tests__/record-path.stageChange-28199.test.tsx b/ui/packages/plugin-detail/src/renderers/__tests__/record-path.stageChange-28199.test.tsx new file mode 100644 index 00000000000..6d1cd696cc7 --- /dev/null +++ b/ui/packages/plugin-detail/src/renderers/__tests__/record-path.stageChange-28199.test.tsx @@ -0,0 +1,274 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * `record:path` with `allowStageChange: true` (objectstack#28061, console half + * objectstack#28199): a stage press asks for confirmation, then writes the + * status field through the record update with the record's concurrency token; + * a server refusal is shown in place; a reader who may not edit the status + * field — and every path that does not opt in — keeps the readout. + * + * The subject is imported relatively and `@object-ui/*` resolves to source + * through the root vitest alias table, so nothing here reads a `dist/`. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { InlineEditProvider, RecordContextProvider, useInlineEdit } from '@object-ui/react'; + +const { permsStub, perms } = vi.hoisted(() => { + const perms: { isLoaded: boolean; writeDenied: string[] } = { isLoaded: true, writeDenied: [] }; + return { + perms, + permsStub: { + get isLoaded() { return perms.isLoaded; }, + checkField: (_object: string, field: string, action: string) => + action === 'read' ? true : !perms.writeDenied.includes(field), + check: () => ({ allowed: true }), + getFieldPermissions: () => [], + getRowFilter: () => undefined, + getObjectApiOperations: () => undefined, + roles: [], + userId: null, + systemPermissions: undefined, + hasCapabilities: () => true, + can: () => true, + cannot: () => false, + }, + }; +}); + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, usePermissions: () => permsStub as any }; +}); + +import { RecordPathRenderer } from '../record-path'; + +const STAGES = [ + { value: 'draft', label: 'Draft' }, + { value: 'in_review', label: 'In review' }, + { value: 'accepted', label: 'Accepted' }, + { value: 'declined', label: 'Declined', terminal: 'lost' as const }, +]; + +const OBJECT_SCHEMA = { + name: 'crm_quote', + fields: { + status: { type: 'select', label: 'Status' }, + amount: { type: 'currency', label: 'Amount' }, + }, +}; + +const VERSION = '2026-10-30T12:00:00.000Z'; + +function makeDataSource(update: (...args: any[]) => Promise = async () => ({})) { + return { update: vi.fn(update) }; +} + +interface MountOptions { + allowStageChange?: boolean; + canEdit?: boolean; + dataSource?: ReturnType; + refresh?: () => Promise; + objectSchema?: Record; + editing?: boolean; +} + +function EnterEditing() { + // Puts the page's edit session into editing, the way a field pencil does. + const inline = useInlineEdit(); + const enter = inline?.enter; + React.useEffect(() => { enter?.(); }, [enter]); + return null; +} + +function mount(opts: MountOptions = {}) { + const dataSource = opts.dataSource ?? makeDataSource(); + const refresh = opts.refresh ?? vi.fn(async () => {}); + const schema: Record = { statusField: 'status', stages: STAGES }; + if (opts.allowStageChange !== undefined) schema.allowStageChange = opts.allowStageChange; + const r = render( + + + {opts.editing && } + + + , + ); + return { ...r, dataSource, refresh }; +} + +/** Both rows (desktop + phone) are in the DOM; the first is the desktop rail. */ +const rows = (container: HTMLElement): HTMLElement[] => + Array.from(container.querySelectorAll('[role="list"]')) as HTMLElement[]; + +async function pressAndConfirm(container: HTMLElement, stageLabel: string) { + fireEvent.click(within(rows(container)[0]).getByRole('button', { name: `Move to ${stageLabel}` })); + const dialog = await screen.findByRole('alertdialog'); + expect(within(dialog).getByText(`Move this record to ${stageLabel}?`)).toBeInTheDocument(); + fireEvent.click(within(dialog).getByRole('button', { name: 'Move' })); +} + +beforeEach(() => { + perms.isLoaded = true; + perms.writeDenied = []; +}); + +afterEach(() => cleanup()); + +describe('record:path allowStageChange — a press writes the stage', () => { + it('every stage but the current one is a button, on both rows', () => { + const { container } = mount({ allowStageChange: true }); + for (const row of rows(container)) { + const names = within(row).getAllByRole('button').map((b) => b.getAttribute('aria-label')); + expect(names).toEqual(['Move to In review', 'Move to Accepted', 'Move to Declined']); + // The listitem semantics and the state names stay as they were. + expect(row.querySelector('[aria-current="step"]')).toHaveAttribute('aria-label', 'Draft, current stage'); + } + }); + + it('confirming writes { statusField: value } through update with the record version, then re-reads', async () => { + const { container, dataSource, refresh } = mount({ allowStageChange: true }); + await pressAndConfirm(container, 'In review'); + await waitFor(() => expect(dataSource.update).toHaveBeenCalledTimes(1)); + expect(dataSource.update).toHaveBeenCalledWith('crm_quote', 'q1', { status: 'in_review' }, { ifMatch: VERSION }); + await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1)); + expect(container.querySelector('[data-stage-change-refusal]')).toBeNull(); + }); + + it('cancelling the confirmation writes nothing', async () => { + const { container, dataSource } = mount({ allowStageChange: true }); + fireEvent.click(within(rows(container)[0]).getByRole('button', { name: 'Move to Accepted' })); + const dialog = await screen.findByRole('alertdialog'); + fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); + expect(dataSource.update).not.toHaveBeenCalled(); + }); +}); + +describe('record:path allowStageChange — a refusal renders in place', () => { + it('an illegal state_machine transition shows the server\'s sentence under the path', async () => { + const refusal = Object.assign(new Error('VALIDATION_FAILED'), { + code: 'VALIDATION_FAILED', + fields: [{ field: 'status', code: 'state_machine', message: 'A quote cannot go from Draft to Accepted.' }], + }); + const { container, refresh } = mount({ + allowStageChange: true, + dataSource: makeDataSource(async () => { throw refusal; }), + }); + await pressAndConfirm(container, 'Accepted'); + const alert = await within(container).findByRole('alert'); + expect(alert).toHaveAttribute('data-stage-change-refusal'); + expect(within(alert).getAllByRole('listitem').map((li) => li.textContent)).toEqual([ + 'A quote cannot go from Draft to Accepted.', + ]); + expect(refresh).not.toHaveBeenCalled(); + }); + + it('a required field elsewhere on the record is named by its label', async () => { + const refusal = Object.assign(new Error('VALIDATION_FAILED'), { + code: 'VALIDATION_FAILED', + fields: [{ field: 'amount', code: 'required', message: 'is required to accept a quote' }], + }); + const { container } = mount({ + allowStageChange: true, + dataSource: makeDataSource(async () => { throw refusal; }), + }); + await pressAndConfirm(container, 'Accepted'); + const alert = await within(container).findByRole('alert'); + expect(alert).toHaveTextContent('Amount: is required to accept a quote'); + }); + + it('a permission refusal reads as a fixed line, not the raw 403 text', async () => { + const refusal = Object.assign(new Error('[Security] PERMISSION_DENIED: no update on crm_quote'), { + httpStatus: 403, + code: 'PERMISSION_DENIED', + }); + const { container } = mount({ + allowStageChange: true, + dataSource: makeDataSource(async () => { throw refusal; }), + }); + await pressAndConfirm(container, 'In review'); + const alert = await within(container).findByRole('alert'); + expect(alert).toHaveTextContent("You don't have permission to change this record's stage."); + }); + + it('a stale concurrency token says so and re-reads the record', async () => { + const refusal = Object.assign(new Error('CONCURRENT_UPDATE'), { code: 'CONCURRENT_UPDATE' }); + const { container, refresh } = mount({ + allowStageChange: true, + dataSource: makeDataSource(async () => { throw refusal; }), + }); + await pressAndConfirm(container, 'In review'); + const alert = await within(container).findByRole('alert'); + expect(alert).toHaveTextContent('Someone else changed this record'); + await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1)); + }); + + it('the next press clears the last refusal', async () => { + const refusal = Object.assign(new Error('nope'), { code: 'VALIDATION_FAILED', fields: [{ field: 'status', message: 'Not from here.' }] }); + const { container } = mount({ + allowStageChange: true, + dataSource: makeDataSource(async () => { throw refusal; }), + }); + await pressAndConfirm(container, 'Accepted'); + await within(container).findByRole('alert'); + fireEvent.click(within(rows(container)[0]).getByRole('button', { name: 'Move to In review' })); + expect(within(container).queryByRole('alert')).toBeNull(); + }); +}); + +describe('record:path allowStageChange — who sees the readout', () => { + const noButtons = (container: HTMLElement) => { + for (const row of rows(container)) { + expect(within(row).queryAllByRole('button')).toHaveLength(0); + expect(row.querySelectorAll('button,[tabindex]')).toHaveLength(0); + } + }; + + it('control: the opt-in absent keeps the readout, for a reader who could edit', () => { + const { container } = mount({}); + expect(rows(container)).toHaveLength(2); + noButtons(container); + }); + + it('control: the opt-in false keeps the readout', () => { + noButtons(mount({ allowStageChange: false }).container); + }); + + it('a reader the page may not let edit this record sees the readout', () => { + noButtons(mount({ allowStageChange: true, canEdit: false }).container); + }); + + it('a reader without the edit grant on the status field sees the readout', () => { + perms.writeDenied = ['status']; + noButtons(mount({ allowStageChange: true }).container); + }); + + it('a read-only status field is never pressable', () => { + noButtons( + mount({ + allowStageChange: true, + objectSchema: { name: 'crm_quote', fields: { status: { type: 'select', readonly: true } } }, + }).container, + ); + }); + + it('while the record\'s inline edit holds a draft, the path is a readout', async () => { + const { container } = mount({ allowStageChange: true, editing: true }); + await waitFor(() => noButtons(container)); + }); +}); From bc74bc549ba4e999a8282c39b1388994b4608fab Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:03:49 +0000 Subject: [PATCH 3/4] chore: regenerate the sdui manifest and component reference; changeset for the record:path stage press Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyyvCaZXvGAh38AKjD --- .changeset/28199-record-path-stage-change.md | 16 ++++++++++++++++ content/docs/ui/components/index.mdx | 2 +- content/docs/ui/components/record.mdx | 1 + scripts/sdui-manifest.record.json | 4 ++-- sdui.manifest.json | 5 +++++ 5 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 .changeset/28199-record-path-stage-change.md diff --git a/.changeset/28199-record-path-stage-change.md b/.changeset/28199-record-path-stage-change.md new file mode 100644 index 00000000000..2420cddadde --- /dev/null +++ b/.changeset/28199-record-path-stage-change.md @@ -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. diff --git a/content/docs/ui/components/index.mdx b/content/docs/ui/components/index.mdx index 61bbb40c1de..3f9d08f8b66 100644 --- a/content/docs/ui/components/index.mdx +++ b/content/docs/ui/components/index.mdx @@ -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) | diff --git a/content/docs/ui/components/record.mdx b/content/docs/ui/components/record.mdx index 0465b3c1882..86e2c28a47e 100644 --- a/content/docs/ui/components/record.mdx +++ b/content/docs/ui/components/record.mdx @@ -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] diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 7fe1a7b312b..2c24082d8b4 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -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", diff --git a/sdui.manifest.json b/sdui.manifest.json index fdd2963a716..f413dbddd35 100644 --- a/sdui.manifest.json +++ b/sdui.manifest.json @@ -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." } ] }, From 2837cf76ffd8be61c34f21a8cba653ef0632fec3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:33:50 +0000 Subject: [PATCH 4/4] refactor(plugin-detail): type the object-definition reads of the stage press Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyyvCaZXvGAh38AKjD --- .../src/renderers/recordPathStageChange.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/ui/packages/plugin-detail/src/renderers/recordPathStageChange.tsx b/ui/packages/plugin-detail/src/renderers/recordPathStageChange.tsx index e5ba15f11e1..8435b351aa6 100644 --- a/ui/packages/plugin-detail/src/renderers/recordPathStageChange.tsx +++ b/ui/packages/plugin-detail/src/renderers/recordPathStageChange.tsx @@ -99,6 +99,12 @@ export function stageRefusalLines( return [extractWriteErrorMessage(err) ?? t('detail.pathStageChangeFailed')]; } +/** The members of an object definition's field entry this module reads. */ +type FieldDef = { type?: unknown; readonly?: unknown; label?: unknown }; + +const fieldDefOf = (objectSchema: unknown, field: string): FieldDef | undefined => + (objectSchema as { fields?: Record } | null | undefined)?.fields?.[field]; + const canUpdate = (ds: unknown): boolean => { const d = ds as Record | null | undefined; return typeof d?.update === 'function' || typeof d?.updateOne === 'function' || typeof d?.patch === 'function'; @@ -131,7 +137,7 @@ export function useStageChange(allowStageChange: boolean, statusField: string | const objectName = ctx?.objectName; const recordId = ctx?.recordId; const dataSource = ctx?.dataSource; - const fieldDef = statusField ? (ctx?.objectSchema as any)?.fields?.[statusField] : undefined; + const fieldDef = statusField ? fieldDefOf(ctx?.objectSchema, statusField) : undefined; const pressable = allowStageChange && !!statusField @@ -152,8 +158,8 @@ export function useStageChange(allowStageChange: boolean, statusField: string | }, []); const labelOf = (field: string): string => { - const declared = (ctx?.objectSchema as any)?.fields?.[field]?.label; - const fallback = declared == null ? field : String(pickLocalized(declared, language) ?? field); + const declared = fieldDefOf(ctx?.objectSchema, field)?.label; + const fallback = declared == null ? field : String(pickLocalized(declared as Parameters[0], language) ?? field); return objectName ? fieldLabel(objectName, field, fallback) : fallback; };