From 741bc536b83687ba943f249e37ebfaa87a64728c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 14:45:28 +0000 Subject: [PATCH 1/4] wip(ui): edit dialog reads the row's writable verdict and the approval lock Claude-Session: https://claude.ai/code/session_01JQwxbYqMqySPCeFRdqDyHp Co-Authored-By: Claude --- .../app-shell/src/console/AppContent.tsx | 22 +++- .../app-shell/src/hooks/useRecordApprovals.ts | 18 +++ .../app-shell/src/views/RecordDetailView.tsx | 6 +- ui/packages/i18n/src/locales/ar.ts | 1 + ui/packages/i18n/src/locales/de.ts | 1 + ui/packages/i18n/src/locales/en.ts | 3 + ui/packages/i18n/src/locales/es.ts | 1 + ui/packages/i18n/src/locales/fr.ts | 1 + ui/packages/i18n/src/locales/ja.ts | 1 + ui/packages/i18n/src/locales/ko.ts | 1 + ui/packages/i18n/src/locales/pt.ts | 1 + ui/packages/i18n/src/locales/ru.ts | 1 + ui/packages/i18n/src/locales/zh.ts | 1 + .../src/RecordFieldAccessScope.tsx | 117 +++++++++++++++--- ui/packages/plugin-form/src/DrawerForm.tsx | 20 +-- ui/packages/plugin-form/src/ModalForm.tsx | 32 +++-- .../src/closedAffordanceNotice.tsx | 18 ++- ui/packages/plugin-form/src/fieldWriteGate.ts | 46 ++++++- .../plugin-form/src/recordFieldAccess.tsx | 31 +++-- ui/packages/types/src/data.ts | 14 ++- 20 files changed, 282 insertions(+), 54 deletions(-) diff --git a/ui/packages/app-shell/src/console/AppContent.tsx b/ui/packages/app-shell/src/console/AppContent.tsx index 26e56670dd0..16c203e5e91 100644 --- a/ui/packages/app-shell/src/console/AppContent.tsx +++ b/ui/packages/app-shell/src/console/AppContent.tsx @@ -14,7 +14,8 @@ import { useAssistant } from '../assistant/assistantBus.js'; import { ModalForm } from '@object-ui/plugin-form'; import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components'; import { toast } from 'sonner'; -import { useActionRunner, useGlobalUndo, useMutationInvalidationBridge, notifyDataChanged, FilterScopeProvider } from '@object-ui/react'; +import { useActionRunner, useGlobalUndo, useMutationInvalidationBridge, notifyDataChanged, FilterScopeProvider, resolveApprovalLock } from '@object-ui/react'; +import { useRecordApprovals, openApprovalOf } from '../hooks/useRecordApprovals.js'; import { getRecordDisplayName } from '@object-ui/core'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; import type { AppAccessVerdict, ConnectionState } from '@object-ui/data-objectstack'; @@ -829,6 +830,24 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = : currentObjectDef; const isChildFormTask = !!formObjectParam && formObjectParam !== currentObjectDef?.name; + // objectstack#27138 — the record the overlay edits may be held by a pending + // approval that locks it. The record's field projection leaves that lock out + // (the server judges it on the values a save sends), so the dialog reads it + // from the approvals read the record page reads, joined on the wire when the + // page already asked, and through the same rule (`openApprovalOf` + + // `resolveApprovalLock`): the dialog then draws the record read-only with + // the page's lock sentence and no Save. The row has no `approval_status` + // here (the overlay holds only its id), so an unanswered read locks nothing. + const formApprovals = useRecordApprovals( + editingRecord ? formObjectDef?.name : undefined, + editingRecord?.id, + ); + const formApprovalLocked = resolveApprovalLock({ + readAnswered: formApprovals.answered, + openApproval: openApprovalOf(formApprovals), + recordStatus: undefined, + }).locked; + // objectstack-ai/objectstack#26172 — a record save names the record it wrote // (`ux-conventions.write-feedback`): `Task "Quarterly review" created`. The // name is the saved record's display name from the ONE ADR-0079 resolver, @@ -1491,6 +1510,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = {formObjectDef && ( , +): { locked: boolean } | null { + if (approvals.pendingRequest) return { locked: recordLockedByApproval(approvals.pendingRequest) }; + return approvals.recordLock?.locked === true ? { locked: true } : null; +} + /** * Did the current viewer submit this approval request? * diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index 5d1ed05209f..b1c62779f5e 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -52,7 +52,7 @@ import { deriveRecordDetailSections } from './recordDetailSections.js'; import type { DataSource, FeedItem, Reaction } from '@object-ui/types'; import type { ActionDef, ActionParamDef, ConfirmationHandler, ParamCollectionHandler } from '@object-ui/core'; import type { ConsoleActionDispatch } from '../consoleActionDispatch.js'; -import { useRecordApprovals, recordLockedByApproval, isSubmitterOf } from '../hooks/useRecordApprovals.js'; +import { useRecordApprovals, openApprovalOf, isSubmitterOf } from '../hooks/useRecordApprovals.js'; import { RecordAttachmentsPanel } from './RecordAttachmentsPanel.js'; import { RecordApprovalsPanel } from './RecordApprovalsPanel.js'; import { DeclaredActionsBar } from './DeclaredActionsBar.js'; @@ -1860,9 +1860,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri const lockedForReader = !approvals.pendingRequest && approvals.recordLock?.locked === true; const { pending: approvalPending, locked: approvalLocked } = resolveApprovalLock({ readAnswered: approvals.answered, - openApproval: approvals.pendingRequest - ? { locked: recordLockedByApproval(approvals.pendingRequest) } - : lockedForReader ? { locked: true } : null, + openApproval: openApprovalOf(approvals), recordStatus: (pageRecord as any)?.approval_status, }); // How far the pending node's tally has got (objectstack#4478). Multi-approver diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 248e115a1d5..03e67d77f91 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -179,6 +179,7 @@ const ar = { noPermissionToSave: "ليس لديك إذن لحفظ هذا السجل.", noPermissionToCreate: "ليس لديك إذن لإنشاء سجلات {{object}}. الحقول للقراءة فقط.", noPermissionToEdit: "ليس لديك إذن لتعديل سجلات {{object}}. الحقول للقراءة فقط.", + noPermissionToEditRecord: "ليس لديك إذن لتعديل هذا السجل. الحقول للقراءة فقط.", submitFailed: "تعذّر الحفظ. يرجى المحاولة مرة أخرى.", uploadInFlight: "انتظر حتى ينتهي الرفع قبل الحفظ.", clearedOnHide: "تم مسح ما لم يعد ينطبق على القيم الحالية: {{fields}}", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 50b16d7fb2a..20143130ce7 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -165,6 +165,7 @@ const de = { noPermissionToSave: "Sie haben keine Berechtigung, diesen Datensatz zu speichern.", noPermissionToCreate: "Sie haben keine Berechtigung, „{{object}}“-Datensätze zu erstellen. Die Felder sind schreibgeschützt.", noPermissionToEdit: "Sie haben keine Berechtigung, „{{object}}“-Datensätze zu bearbeiten. Die Felder sind schreibgeschützt.", + noPermissionToEditRecord: "Sie haben keine Berechtigung, diesen Datensatz zu bearbeiten. Die Felder sind schreibgeschützt.", submitFailed: "Speichern fehlgeschlagen. Bitte versuchen Sie es erneut.", uploadInFlight: "Warten Sie, bis der Upload abgeschlossen ist, bevor Sie speichern.", clearedOnHide: "Geleert, was für die aktuellen Werte nicht mehr gilt: {{fields}}", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 4d892aa4091..9675f0c0d31 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -220,6 +220,9 @@ const en = { // (objectui#11000). `{{object}}` is the object's label. noPermissionToCreate: "You don't have permission to create {{object}} records. The fields are read-only.", noPermissionToEdit: "You don't have permission to edit {{object}} records. The fields are read-only.", + // The same notice when THIS record refuses the update while the object + // allows it: sharing, ownership or row-level security (objectstack#27138). + noPermissionToEditRecord: "You don't have permission to edit this record. The fields are read-only.", submitFailed: 'Could not save. Please try again.', uploadInFlight: 'Wait for the upload to finish before saving.', // The notice the record form raises when a field's own `visibleWhen` turns diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 8668359a94b..2c3a07dd0d8 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -172,6 +172,7 @@ const es = { noPermissionToSave: "No tienes permiso para guardar este registro.", noPermissionToCreate: "No tienes permiso para crear registros de {{object}}. Los campos son de solo lectura.", noPermissionToEdit: "No tienes permiso para editar registros de {{object}}. Los campos son de solo lectura.", + noPermissionToEditRecord: "No tienes permiso para editar este registro. Los campos son de solo lectura.", submitFailed: "No se pudo guardar. Inténtalo de nuevo.", uploadInFlight: "Espere a que finalice la carga antes de guardar.", clearedOnHide: "Se borró lo que ya no corresponde a los valores actuales: {{fields}}", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index d60a72b2b6d..9c249de752b 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -167,6 +167,7 @@ const fr = { noPermissionToSave: "Vous n'avez pas l'autorisation d'enregistrer cet enregistrement.", noPermissionToCreate: "Vous n'avez pas l'autorisation de créer des enregistrements {{object}}. Les champs sont en lecture seule.", noPermissionToEdit: "Vous n'avez pas l'autorisation de modifier les enregistrements {{object}}. Les champs sont en lecture seule.", + noPermissionToEditRecord: "Vous n'avez pas l'autorisation de modifier cet enregistrement. Les champs sont en lecture seule.", submitFailed: "Échec de l'enregistrement. Veuillez réessayer.", uploadInFlight: "Attendez la fin du téléversement avant de sauvegarder.", clearedOnHide: "Ce qui ne s'applique plus aux valeurs actuelles a été effacé : {{fields}}", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index f9ab120386f..237ff438a4c 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -165,6 +165,7 @@ const ja = { noPermissionToSave: "このレコードを保存する権限がありません。", noPermissionToCreate: "{{object}}を作成する権限がありません。フィールドは読み取り専用です。", noPermissionToEdit: "{{object}}を編集する権限がありません。フィールドは読み取り専用です。", + noPermissionToEditRecord: "このレコードを編集する権限がありません。フィールドは読み取り専用です。", submitFailed: "保存できませんでした。もう一度お試しください。", uploadInFlight: "アップロードが完了してから保存してください。", clearedOnHide: "現在の値に該当しなくなった項目をクリアしました: {{fields}}", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 1ae331d2674..d074299d731 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -165,6 +165,7 @@ const ko = { noPermissionToSave: "이 레코드를 저장할 권한이 없습니다.", noPermissionToCreate: "{{object}} 생성 권한이 없습니다. 필드는 읽기 전용입니다.", noPermissionToEdit: "{{object}} 편집 권한이 없습니다. 필드는 읽기 전용입니다.", + noPermissionToEditRecord: "이 레코드를 편집할 권한이 없습니다. 필드는 읽기 전용입니다.", submitFailed: "저장하지 못했습니다. 다시 시도해 주세요.", uploadInFlight: "업로드가 완료된 후에 저장하세요.", clearedOnHide: "현재 값에 더 이상 해당하지 않는 항목을 비웠습니다: {{fields}}", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index ba5760102a8..b0013fea90c 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -167,6 +167,7 @@ const pt = { noPermissionToSave: "Você não tem permissão para salvar este registro.", noPermissionToCreate: "Você não tem permissão para criar registros de {{object}}. Os campos são somente leitura.", noPermissionToEdit: "Você não tem permissão para editar registros de {{object}}. Os campos são somente leitura.", + noPermissionToEditRecord: "Você não tem permissão para editar este registro. Os campos são somente leitura.", submitFailed: "Não foi possível salvar. Tente novamente.", uploadInFlight: "Aguarde a conclusão do upload antes de salvar.", clearedOnHide: "O que não se aplica mais aos valores atuais foi limpo: {{fields}}", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index f32b45201f8..e35dc3ddcfc 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -172,6 +172,7 @@ const ru = { noPermissionToSave: "У вас нет прав на сохранение этой записи.", noPermissionToCreate: "У вас нет прав на создание записей «{{object}}». Поля доступны только для чтения.", noPermissionToEdit: "У вас нет прав на редактирование записей «{{object}}». Поля доступны только для чтения.", + noPermissionToEditRecord: "У вас нет прав на редактирование этой записи. Поля доступны только для чтения.", submitFailed: "Не удалось сохранить. Попробуйте ещё раз.", uploadInFlight: "Дождитесь завершения загрузки, прежде чем сохранять.", clearedOnHide: "Очищено то, что больше не применимо к текущим значениям: {{fields}}", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 578dab47277..2eff68845fd 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -170,6 +170,7 @@ const zh = { noPermissionToSave: '您没有权限保存这条记录。', noPermissionToCreate: '您没有创建{{object}}的权限,字段均为只读。', noPermissionToEdit: '您没有编辑{{object}}的权限,字段均为只读。', + noPermissionToEditRecord: '您没有编辑此记录的权限,字段均为只读。', submitFailed: '保存失败,请重试。', uploadInFlight: '请等待上传完成后再保存。', clearedOnHide: '已清空不再适用于当前取值的字段:{{fields}}', diff --git a/ui/packages/permissions/src/RecordFieldAccessScope.tsx b/ui/packages/permissions/src/RecordFieldAccessScope.tsx index feb3b4efa43..948fb29ceea 100644 --- a/ui/packages/permissions/src/RecordFieldAccessScope.tsx +++ b/ui/packages/permissions/src/RecordFieldAccessScope.tsx @@ -14,12 +14,31 @@ import { PermCtx, type PermissionContextValue } from './PermissionContext.js'; * The part of the server's record-scoped field projection this scope reads * (`GET /data/{object}/{id}/field-access`, `GetRecordFieldAccessResponse` in * `@objectstack/spec`): the fields the caller may write on THIS record, as - * field-level security judges an update of the stored row. + * field-level security judges an update of the stored row, and whether the + * row may be updated at all. */ export interface RecordFieldAccessAnswer { readonly editable: readonly string[]; + /** + * The write gate's verdict on the row itself (the object grant, sharing, + * ownership, row-level security). Only `false` changes anything below the + * scope; `true` and an absent key (a server that predates the verdict) + * leave the row's write question to the enclosing provider. + */ + readonly writable?: boolean; } +/** + * The `reason` a scoped `check(object, 'update')` answers with when THIS row + * refuses the update while the object grant allows it. A form reads it to say + * why every field is locked (`closedFormAffordance` in `@object-ui/plugin-form`). + * + * - `record-not-writable` — the projection answered `writable: false`; + * - `record-locked-by-approval` — the host reported a pending approval that + * locks the record (`lockedBy: 'approval'`). + */ +type RecordWriteRefusal = 'record-not-writable' | 'record-locked-by-approval'; + export interface RecordFieldAccessScopeProps { /** The object the record belongs to. Matched case-insensitively, as `MePermissionsProvider` matches object names. */ object: string; @@ -39,6 +58,15 @@ export interface RecordFieldAccessScopeProps { * from the earlier answer. */ revision?: number; + /** + * A lock on the record that the projection does not judge and the host + * reads from its own door: `'approval'` while a pending approval locks the + * record (the approvals service's record lock, answered to every reader). + * The row then answers as not writable, exactly as `writable: false` does, + * without waiting for the projection. The host passes the verdict it already + * draws its own lock from; the scope never re-derives it. + */ + lockedBy?: 'approval'; children: ReactNode; } @@ -86,12 +114,15 @@ const scopeKey = (object: string, recordId: string) => JSON.stringify([(object ? * * The projection never widens past the enclosing provider on anything but * that lock, and on the lock it is the write gate's own verdict for the row. + * A row that refuses the update as a whole (`writable: false`, or a lock the + * host reports) is layered on top by {@link refusedRow}, which only narrows. */ function scoped( parent: PermissionContextValue, object: string, recordId: string, answer: RecordFieldAccessAnswer | null, + lockedBy: RecordFieldAccessScopeProps['lockedBy'], ): PermissionContextValue { const key = scopeKey(object, recordId); let byRecord = SCOPED.get(parent); @@ -99,10 +130,11 @@ function scoped( byRecord = new Map(); SCOPED.set(parent, byRecord); } - let byAnswer = byRecord.get(key); + const cacheKey = lockedBy ? `${key}|${lockedBy}` : key; + let byAnswer = byRecord.get(cacheKey); if (!byAnswer) { byAnswer = new WeakMap(); - byRecord.set(key, byAnswer); + byRecord.set(cacheKey, byAnswer); } const answerKey = answer ?? NO_ANSWER; const hit = byAnswer.get(answerKey); @@ -133,9 +165,49 @@ function scoped( }, } : { ...parent }; - SCOPE_OF.set(value, key); - byAnswer.set(answerKey, value); - return value; + const refusal: RecordWriteRefusal | undefined = + answer?.writable === false ? 'record-not-writable' + : lockedBy === 'approval' ? 'record-locked-by-approval' + : undefined; + const result = refusal ? refusedRow(value, objKey, recordId, refusal) : value; + SCOPE_OF.set(result, key); + byAnswer.set(answerKey, result); + return result; +} + +/** + * The row's own write verdict over `base`: THIS record refuses every update, + * so no field of it is writable and the object's `update` is refused for it. + * + * Only the update of this row is answered. `read` and `create` keep the + * enclosing answer, a `check` that names another record of the object keeps + * it too, and a `check` the enclosing provider already refuses keeps its own + * reason (the object grant is the wider refusal, and its sentence is the one + * a form should show). + */ +function refusedRow( + base: PermissionContextValue, + objKey: string, + recordId: string, + refusal: RecordWriteRefusal, +): PermissionContextValue { + const ownRecord = (record: Record | undefined): boolean => { + if (!record) return true; + const id = record.id ?? record._id; + return id === undefined || id === null || String(id) === recordId; + }; + return { + ...base, + check: (o, action, record) => { + const verdict = base.check(o, action, record); + if (action !== 'update' || (o ?? '').toLowerCase() !== objKey || !verdict.allowed || !ownRecord(record)) { + return verdict; + } + return { allowed: false, reason: refusal }; + }, + checkField: (o, field, action) => + action === 'write' && (o ?? '').toLowerCase() === objKey ? false : base.checkField(o, field, action), + }; } /** @@ -155,17 +227,25 @@ function scoped( * `revision` moves (the row was saved or re-read), the scope asks again and * the lock stands until the new answer lands. * - * ⛔ The projection is field-level only. The record's row-level write scope - * (sharing, row-level security, ownership) and the object grant are not part - * of it: a row the caller may read but not write still answers its fields - * editable here, and the server refuses the save as a whole, as it did before - * this scope existed. The form-wide lock (`gateFormFields` in - * `@object-ui/plugin-form`) keeps reading the object grant. + * The row's own write verdict rides on the same answer (objectstack#27138). + * `editable` is field-level only; the projection's `writable` is the write + * gate's verdict on the row itself — sharing, row-level security, ownership + * and the object grant. On `writable: false` every field of the record + * answers `write` refused and `check(object, 'update')` answers refused with + * the reason `record-not-writable`, so the field gates and the form-wide lock + * (`gateFormFields` in `@object-ui/plugin-form`) and the detail page's inline + * edit all draw the record read-only from the one source, and a form can say + * why. `lockedBy: 'approval'` answers the same way with the reason + * `record-locked-by-approval`: the approvals record lock is judged on the + * values a save sends, so the projection leaves it out, and the host passes + * the lock it already reads. `writable: true` or an absent `writable` changes + * nothing. Until the answer arrives the row-blind answer stands for the row, + * as it does for the fields. * * With no enclosing provider there is no field answer to refine, so the scope * renders its children as they are and asks nothing. */ -export function RecordFieldAccessScope({ object, recordId, load, revision = 0, children }: RecordFieldAccessScopeProps) { +export function RecordFieldAccessScope({ object, recordId, load, revision = 0, lockedBy, children }: RecordFieldAccessScopeProps) { const parent = useContext(PermCtx); const key = scopeKey(object, recordId); const nested = !!parent && SCOPE_OF.get(parent) === key; @@ -186,7 +266,14 @@ export function RecordFieldAccessScope({ object, recordId, load, revision = 0, c // eslint-disable-next-line react-hooks/exhaustive-deps }, [!!parent, nested, key, revision]); - if (!parent || nested) return <>{children}; + if (!parent) return <>{children}; + // A scope nested under one for the same record reuses its answer, and adds + // only a lock the outer scope was not told of. + if (nested) { + return lockedBy + ? {children} + : <>{children}; + } const current = answer?.key === key && answer.revision === revision ? answer.value : null; - return {children}; + return {children}; } diff --git a/ui/packages/plugin-form/src/DrawerForm.tsx b/ui/packages/plugin-form/src/DrawerForm.tsx index ed6bd2ea98e..867da8cef7b 100644 --- a/ui/packages/plugin-form/src/DrawerForm.tsx +++ b/ui/packages/plugin-form/src/DrawerForm.tsx @@ -60,7 +60,7 @@ import { advanceLoadedRecord, type LoadedRecordSnapshot, } from './sanitize'; -import { closedFormAffordance, fieldWriteGate, gateFormFields } from './fieldWriteGate'; +import { closedForRecord, closedFormAffordance, fieldWriteGate, gateFormFields } from './fieldWriteGate'; import { RecordFieldAccessForForm } from './recordFieldAccess'; import { ClosedAffordanceNotice } from './closedAffordanceNotice'; import { seedCreateValues, omitServerResolvedDefaults } from './schemaDefaults'; @@ -725,7 +725,16 @@ const DrawerFormBody: React.FC = ({ // `form.reset()` *before* invoking onCancel, which would wipe the user's // input before the "Discard changes?" prompt even appears — so "Keep editing" // would keep an already-emptied form. Mirrors ModalForm. - const showSubmit = schema.showSubmit !== false && schema.mode !== 'view'; + // objectui#11000 — the form-wide lock's reason, read once for the notice + // above the fields and for Save: a record that refuses the update itself + // (objectstack#27138) takes no edit, so the drawer offers no Save on it. + const closedAffordance = closedFormAffordance({ + perms, + objectName: schema.objectName, + mode: schema.mode, + objectSchema, + }); + const showSubmit = schema.showSubmit !== false && schema.mode !== 'view' && !closedForRecord(closedAffordance); const showCancel = schema.showCancel !== false; const submitLabel = schema.submitText || (schema.mode === 'create' ? t('form.create') : t('form.update')); const cancelLabel = schema.cancelText || t('common.cancel'); @@ -907,12 +916,7 @@ const DrawerFormBody: React.FC = ({ // through the `ObjectForm` it renders. const closedAffordanceNotice = !error && !loading ? ( diff --git a/ui/packages/plugin-form/src/ModalForm.tsx b/ui/packages/plugin-form/src/ModalForm.tsx index 0600ce512fc..7578d68169b 100644 --- a/ui/packages/plugin-form/src/ModalForm.tsx +++ b/ui/packages/plugin-form/src/ModalForm.tsx @@ -64,7 +64,7 @@ import { advanceLoadedRecord, type LoadedRecordSnapshot, } from './sanitize'; -import { closedFormAffordance, fieldWriteGate, gateFormFields } from './fieldWriteGate'; +import { closedForRecord, closedFormAffordance, fieldWriteGate, gateFormFields } from './fieldWriteGate'; import { RecordFieldAccessForForm } from './recordFieldAccess'; import { ClosedAffordanceNotice } from './closedAffordanceNotice'; import { seedCreateValues, omitServerResolvedDefaults } from './schemaDefaults'; @@ -269,6 +269,14 @@ export interface ModalFormProps { schema: ModalFormSchema; dataSource?: DataSource; className?: string; + /** + * A lock on the edited record that the host reads and the record's field + * projection leaves out: `'approval'` while a pending approval locks it + * (objectstack#27138). The dialog then draws the record read-only, says + * why, and offers no Save, as it does for a row the server answers + * `writable: false`. Read on an `edit` form with a `recordId` only. + */ + lockedBy?: 'approval'; } /** @@ -323,8 +331,8 @@ const clampColumns = (n: unknown): number | undefined => * An `edit` form for a stored record answers its field locks from that record * (objectstack#26594) — see `recordFieldAccess.tsx`. */ -export const ModalForm: React.FC = ({ schema, ...rest }) => ( - +export const ModalForm: React.FC = ({ schema, lockedBy, ...rest }) => ( + {/* `schema=` by name, so the handler-key census follows the document into the body. */} @@ -845,7 +853,16 @@ const ModalFormBody: React.FC = ({ // Build base form schema // Actions are hidden inside the form renderer — we render them in a sticky footer instead - const showSubmit = schema.showSubmit !== false && schema.mode !== 'view'; + // objectui#11000 — the form-wide lock's reason, read once for the notice + // above the fields and for Save: a record that refuses the update itself + // (objectstack#27138) takes no edit, so the dialog offers no Save on it. + const closedAffordance = closedFormAffordance({ + perms, + objectName: schema.objectName, + mode: schema.mode, + objectSchema, + }); + const showSubmit = schema.showSubmit !== false && schema.mode !== 'view' && !closedForRecord(closedAffordance); const showCancel = schema.showCancel !== false; const submitLabel = schema.submitText || (schema.mode === 'create' ? t('form.create') : t('form.update')); const cancelLabel = schema.cancelText || t('common.cancel'); @@ -1111,12 +1128,7 @@ const ModalFormBody: React.FC = ({ // through the `ObjectForm` it renders. const closedAffordanceNotice = !error && !loading ? ( diff --git a/ui/packages/plugin-form/src/closedAffordanceNotice.tsx b/ui/packages/plugin-form/src/closedAffordanceNotice.tsx index 0abaf0ae04b..fd3298d7940 100644 --- a/ui/packages/plugin-form/src/closedAffordanceNotice.tsx +++ b/ui/packages/plugin-form/src/closedAffordanceNotice.tsx @@ -36,6 +36,12 @@ import type { ClosedFormAffordance } from './fieldWriteGate'; * caller may read but not edit, a `readonly` field) never shows it: the form's * affordance is open there. * + * On an edit form whose own record refuses the update (objectstack#27138) the + * sentence names the record, not the object: the caller may edit other + * records of it. `record` is the server's row verdict (sharing, ownership, + * row-level security); `approval` a pending approval that locks the record, + * said with the record page's own lock sentence. + * * `role="status"`, the package's pattern for a form state (`UploadInFlightNotice`): * nothing has failed, and an assertive live region would interrupt a * screen-reader user as the form opens. @@ -46,6 +52,8 @@ const useClosedAffordanceTranslation = createSafeTranslation( // compares this table against them. What a provider-less host renders. 'form.noPermissionToCreate': "You don't have permission to create {{object}} records. The fields are read-only.", 'form.noPermissionToEdit': "You don't have permission to edit {{object}} records. The fields are read-only.", + 'form.noPermissionToEditRecord': "You don't have permission to edit this record. The fields are read-only.", + 'detail.lockedTooltip': 'This record has a pending approval request; editing is locked', }, 'form.noPermissionToCreate', ); @@ -76,9 +84,13 @@ export function ClosedAffordanceNotice({ const object = 'objectLabel' in labels ? labels.objectLabel({ name: objectName, label: declared }) : declared; const message = - affordance === 'edit' - ? t('form.noPermissionToEdit', { object }) - : t('form.noPermissionToCreate', { object }); + affordance === 'record' + ? t('form.noPermissionToEditRecord') + : affordance === 'approval' + ? t('detail.lockedTooltip') + : affordance === 'edit' + ? t('form.noPermissionToEdit', { object }) + : t('form.noPermissionToCreate', { object }); return (

>( export interface FormFieldPrincipal extends FieldWritePrincipal { can(object: string, action: 'create' | 'update' | 'delete'): boolean; getObjectApiOperations?: (object: string) => readonly string[] | undefined; + /** + * The verdict `can` is read from, with its reason. Read only to say WHY an + * edit form's lock engaged: below `RecordFieldAccessScope` + * (`@object-ui/permissions`) a row that refuses the update answers `update` + * refused with a row reason (objectstack#27138). + */ + check?(object: string, action: 'update'): { allowed: boolean; reason?: string }; } export interface GateFormFieldsOptions extends ApplyFieldPermissionsOptions { @@ -260,8 +267,35 @@ function managedModeLocked( return !resolveAffordance(affordance, { objectSchema, objectName, perms }).allowed; } -/** The form-level affordance {@link closedFormAffordance} can report closed. */ -export type ClosedFormAffordance = 'create' | 'edit'; +/** + * Why {@link closedFormAffordance} found the form's affordance closed: the + * mode's affordance for the object (`create`, `edit`), or, on an edit form, + * THIS record refusing the update while the object allows it (objectstack#27138) + * — `record` when the server's record projection answered `writable: false` + * (sharing, ownership, row-level security), `approval` when the host reported + * a pending approval that locks the record. + */ +export type ClosedFormAffordance = 'create' | 'edit' | 'record' | 'approval'; + +/** + * The row reasons `RecordFieldAccessScope` (`@object-ui/permissions`) answers + * a refused `update` with, mapped to the affordance they close. Spelled in + * both packages; the scope tests here mount the real scope, so a rename on + * either side reds them. + */ +const ROW_REFUSALS: ReadonlyMap = new Map([ + ['record-not-writable', 'record'], + ['record-locked-by-approval', 'approval'], +]); + +/** + * Whether the form's lock is THIS record refusing the update. Such a record + * takes no edit at all, so a container does not offer its Save: there is + * nothing on the form the server would accept (objectstack#27138). + */ +export function closedForRecord(affordance: ClosedFormAffordance | undefined): boolean { + return affordance === 'record' || affordance === 'approval'; +} /** * Which affordance the form-wide lock found CLOSED for this form, or @@ -271,7 +305,9 @@ export type ClosedFormAffordance = 'create' | 'edit'; * * It is {@link managedModeLocked}, the very predicate {@link gateFormFields} * disables every drawn field on, read for its reason: `create` for a create - * form, `edit` for an edit form. The locked form's notice + * form, `edit` for an edit form, and `record` / `approval` for an edit form + * whose own row refuses the update (the reason the scoped `check` gave). The + * locked form's notice * (`ClosedAffordanceNotice`) and the wizard's navigation gate read THIS, not a * verdict of their own, so a form cannot explain a lock it did not draw, nor * draw one it does not explain. @@ -289,7 +325,9 @@ export function closedFormAffordance({ | ClosedFormAffordance | undefined { if (!managedModeLocked(objectSchema, perms, objectName, mode)) return undefined; - return mode === 'edit' ? 'edit' : 'create'; + if (mode !== 'edit') return 'create'; + const reason = perms?.check?.(objectName, 'update')?.reason; + return (reason !== undefined && ROW_REFUSALS.get(reason)) || 'edit'; } /** diff --git a/ui/packages/plugin-form/src/recordFieldAccess.tsx b/ui/packages/plugin-form/src/recordFieldAccess.tsx index fd3151bb098..6e872c6bac5 100644 --- a/ui/packages/plugin-form/src/recordFieldAccess.tsx +++ b/ui/packages/plugin-form/src/recordFieldAccess.tsx @@ -36,6 +36,15 @@ * signal the form re-reads its record on). A save that moves the row into the * state a `when` names therefore locks the field again, rather than leaving * the earlier answer standing. + * + * The same answer carries the row's own verdict (objectstack#27138): on + * `writable: false` the scope refuses every field of the record and the + * record's update, so the form draws read-only, says why and offers no Save. + * A host that reads a lock the projection leaves out — a pending approval + * that locks the record — passes it as `lockedBy`, and it reaches the same + * gates the same way. A form edits a stored record under that lock even on a + * data source without the projection: the scope then has no answer to ask + * for, and only the host's lock narrows the row. */ import React, { useEffect, useState } from 'react'; @@ -46,17 +55,22 @@ import type { DataSource } from '@object-ui/types'; export interface RecordFieldAccessForFormProps { schema: { objectName?: string; mode?: string; recordId?: string | number | null }; dataSource?: DataSource | null; + /** A lock the host reads on the record and the projection leaves out (`RecordFieldAccessScope`'s `lockedBy`). */ + lockedBy?: 'approval'; children: React.ReactNode; } -export function RecordFieldAccessForForm({ schema, dataSource, children }: RecordFieldAccessForFormProps) { +const NO_PROJECTION = () => Promise.resolve(null); + +export function RecordFieldAccessForForm({ schema, dataSource, lockedBy, children }: RecordFieldAccessForFormProps) { const objectName = schema?.objectName; const recordId = schema?.recordId; const load = dataSource?.getRecordFieldAccess; - const asks = schema?.mode === 'edit' + const editsStored = schema?.mode === 'edit' && !!objectName - && recordId !== undefined && recordId !== null && recordId !== '' - && typeof load === 'function'; + && recordId !== undefined && recordId !== null && recordId !== ''; + const asks = editsStored && typeof load === 'function'; + const scopes = asks || (editsStored && !!lockedBy); const id = asks ? String(recordId) : undefined; const busRevision = useDataInvalidation(asks ? objectName : undefined, id); const [writeRevision, setWriteRevision] = useState(0); @@ -69,15 +83,18 @@ export function RecordFieldAccessForForm({ schema, dataSource, children }: Recor setWriteRevision((n) => n + 1); }); }, [asks, dataSource, objectName, id]); - if (!asks) { + if (!scopes) { return <>{children}; } return ( (load as NonNullable).call(dataSource, object, recordKey)} + recordId={String(recordId)} + load={asks + ? (object, recordKey) => (load as NonNullable).call(dataSource, object, recordKey) + : NO_PROJECTION} revision={busRevision + writeRevision} + lockedBy={lockedBy} > {children} diff --git a/ui/packages/types/src/data.ts b/ui/packages/types/src/data.ts index 8c963c1ddb3..e89071d764b 100644 --- a/ui/packages/types/src/data.ts +++ b/ui/packages/types/src/data.ts @@ -358,10 +358,20 @@ export interface RecordFieldAccess { /** * Fields the caller may write on this record, as field-level security * judges an update of the stored row. The object grant, the record's - * row-level write scope and a field's own rules (readonly, computed types) - * are not part of it. + * row-level write scope (see `writable`) and a field's own rules + * (readonly, computed types) are not part of it. */ editable: string[]; + /** + * Whether the caller may update this record at all, judged by the write + * gate on the stored row: the object grant, record sharing and ownership, + * row-level security. `false` means an edit of this record is refused + * whole, whatever `editable` lists. `true` does not promise that a save + * succeeds: a pending approval that locks the record has its own reader + * (the approvals record lock), and validation is judged on the values + * sent. Absent from a server that predates the verdict. + */ + writable?: boolean; } /** From 39b61df6d53d00e8a39a8854da4bde0bbb1ca2fa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 14:53:40 +0000 Subject: [PATCH 2/4] test(ui): pin the edit dialog's row verdict, the approval lock and the inline edit Claude-Session: https://claude.ai/code/session_01JQwxbYqMqySPCeFRdqDyHp Co-Authored-By: Claude --- .changeset/27138-edit-dialog-row-writable.md | 17 ++ ...tent.editDialogApprovalLock-27138.test.tsx | 209 ++++++++++++++++++ ...ieldAccessScope.rowWritable-27138.test.tsx | 151 +++++++++++++ .../record-blocks.rowWritable-27138.test.tsx | 169 ++++++++++++++ ...cordFieldAccess.rowWritable-27138.test.tsx | 165 ++++++++++++++ 5 files changed, 711 insertions(+) create mode 100644 .changeset/27138-edit-dialog-row-writable.md create mode 100644 ui/packages/app-shell/src/console/__tests__/AppContent.editDialogApprovalLock-27138.test.tsx create mode 100644 ui/packages/permissions/src/__tests__/RecordFieldAccessScope.rowWritable-27138.test.tsx create mode 100644 ui/packages/plugin-detail/src/renderers/__tests__/record-blocks.rowWritable-27138.test.tsx create mode 100644 ui/packages/plugin-form/src/recordFieldAccess.rowWritable-27138.test.tsx diff --git a/.changeset/27138-edit-dialog-row-writable.md b/.changeset/27138-edit-dialog-row-writable.md new file mode 100644 index 00000000000..f70234fc825 --- /dev/null +++ b/.changeset/27138-edit-dialog-row-writable.md @@ -0,0 +1,17 @@ +--- +'@object-ui/types': patch +'@object-ui/permissions': patch +'@object-ui/plugin-form': patch +'@object-ui/i18n': patch +--- + +fix(plugin-form): the record edit dialog draws a record the caller may read but not write as read-only, says why, and offers no Save + +Clause-②: no +Metadata-protocol: none + +On a record shared to the caller for reading only (sharing, ownership or row-level security), or held by a pending approval that locks it, the console's edit dialog used to open with every field live; the user filled it in and the server refused the save as a whole. The dialog now asks the server about the row itself: + +- **The row's verdict.** `RecordFieldAccess` (`@object-ui/types`) carries the record projection's `writable`, which `DataSource.getRecordFieldAccess` already passes through. Below `RecordFieldAccessScope` (`@object-ui/permissions`), `writable: false` refuses `write` on every field of the record and `check(object, 'update')` with the reason `record-not-writable`; `read`, `create`, other objects and other records keep the enclosing answer, and an object grant the provider already refuses keeps its own reason. `writable: true` and an absent `writable` (an older server) change nothing. +- **The approval lock.** The projection leaves out the approvals record lock, which the server judges on the values a save sends. `RecordFieldAccessScope` takes `lockedBy: 'approval'` for a lock the host reads, answered the same way with the reason `record-locked-by-approval`, and `ModalForm` takes the same `lockedBy` prop. The console's edit dialog passes it from the approvals read and rule the record page uses. +- **What the form draws.** Every field gate (`gateFormFields`, the outbound write gate, the record page's inline edit) reads the scoped answer with no call-site change. `closedFormAffordance` reports `record` or `approval` for such a row, the form's notice says "You don't have permission to edit this record. The fields are read-only." (new key `form.noPermissionToEditRecord` in every pack) or the record page's approval-lock sentence, and `ModalForm` and `DrawerForm` offer no Save on it. diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.editDialogApprovalLock-27138.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.editDialogApprovalLock-27138.test.tsx new file mode 100644 index 00000000000..03da39e8a99 --- /dev/null +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.editDialogApprovalLock-27138.test.tsx @@ -0,0 +1,209 @@ +/** + * 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. + */ + +/** + * objectstack#27138 — the console's edit dialog reads the approvals record + * lock of the record it edits, through the read and the rule the record page + * uses (`useRecordApprovals`, `openApprovalOf`, `resolveApprovalLock`), and + * hands it to `ModalForm` as `lockedBy: 'approval'`. The record's field + * projection leaves that lock out, so without it the dialog offered fields + * a save then refused. + * + * Measured from a real `AppContent` mount (the harness of + * `AppContent.formViewModalSize-12803`), with the approvals routes served by a + * fetch double: + * - a reader outside the slate: the list answers empty and the record lock + * answers `locked: true` → `lockedBy: 'approval'`; + * - a participant: the pending request's node locks (`lock_record` absent) + * → `'approval'`; it declares `lockRecord: false` → no lock; + * - CONTROL no approval running → no lock; + * - CONTROL a create dialog asks nothing and is not locked. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { render, cleanup, waitFor, screen } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { registerAllFields } from '@object-ui/fields'; + +registerAllFields(); + +const probe = vi.hoisted(() => ({ + /** Every set of props the global record-form modal was rendered with. */ + modalProps: [] as any[], +})); + +vi.mock('@object-ui/plugin-form', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + ModalForm: (props: any) => { + probe.modalProps.push(props); + return

; + }, + }; +}); + +vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({ + ...(await importOriginal()), + CreateAppPage: () =>
, + EditAppPage: () =>
, + DashboardDesignPage: () =>
, +})); + +vi.mock('../../layout/ConsoleLayout', () => ({ + ConsoleLayout: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), +})); +vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null })); +vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null })); +vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null })); +vi.mock('../../views/ObjectView', () => ({ ObjectView: () =>
})); +vi.mock('../../views/RecordFormPage', () => ({ + RecordFormPage: () =>
, +})); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ + user: { id: 'u1', name: 'Ada', email: 'ada@example.com', role: 'user' }, + getAuthConfig: async () => ({ features: {} }), + activeOrganization: null, + }), + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), +})); + +const CONTRACT = { + name: 'crm_contract', + label: 'Contract', + fields: { title: { type: 'text', label: 'Title' } }, + form: { type: 'modal' }, +}; +const OBJECTS = [CONTRACT]; + +const dataSourceStub = { + onConnectionStateChange: () => () => {}, + getConnectionState: () => 'connected', + getObjectSchema: vi.fn(async (name: string) => OBJECTS.find((o) => o.name === name)), + findOne: vi.fn(async () => ({ id: 'r1' })), + find: vi.fn(async () => ({ data: [] })), + create: vi.fn(), + update: vi.fn(), +}; +vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => dataSourceStub, +})); + +const APPS = [{ name: 'crm', label: 'CRM', isDefault: true, navigation: [] }]; + +// One value for the whole run: effects key on these members. +const METADATA = { + apps: APPS, + objects: OBJECTS, + loading: false, + ensureType: undefined, + error: null, + refresh: async () => {}, +}; +vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadata: () => METADATA, +})); + +const ACTION_RUNNER = { execute: () => {}, runner: { registerHandler: () => {}, getContext: () => ({}) } }; +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useActionRunner: () => ACTION_RUNNER, + useGlobalUndo: () => {}, + useMutationInvalidationBridge: () => {}, +})); + +import { AppContent } from '../AppContent'; +import { __resetRecordApprovalsStore } from '../../hooks/useRecordApprovals'; + +type Approvals = { requests: any[]; lock: boolean }; + +function serveApprovals({ requests, lock }: Approvals) { + const fetchSpy = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const body = url.includes('/approvals/records/') + ? { success: true, data: { locked: lock } } + : url.includes('/approvals/requests?') + ? { data: requests } + : url.includes('/approvals/requests/') + ? { data: requests[0] } + : {}; + return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + vi.stubGlobal('fetch', fetchSpy); + return fetchSpy; +} + +const approvalReads = (fetchSpy: ReturnType) => + fetchSpy.mock.calls.map(([u]) => String(u)).filter((u) => u.includes('/approvals/')); + +function openAt(url: string) { + render( + + + } /> + + , + ); +} + +const lastModal = () => probe.modalProps[probe.modalProps.length - 1]; + +const PENDING = { id: 'req1', object_name: 'crm_contract', record_id: 'r1', status: 'pending' }; + +beforeEach(() => { + probe.modalProps.length = 0; + __resetRecordApprovalsStore(); + vi.clearAllMocks(); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('[#27138] the console edit dialog reads the approvals record lock', () => { + it.each([ + ['a reader outside the slate (the record lock answers)', { requests: [], lock: true }], + ['a participant whose pending node locks the record', { requests: [PENDING], lock: false }], + ])('%s: the dialog is handed the approval lock', async (_label, approvals) => { + const fetchSpy = serveApprovals(approvals); + openAt('/apps/crm/crm_contract?form=r1'); + await waitFor(() => expect(lastModal()?.schema?.mode).toBe('edit')); + await waitFor(() => expect(lastModal().lockedBy).toBe('approval')); + expect(approvalReads(fetchSpy).some((u) => u.includes('object=crm_contract') && u.includes('recordId=r1'))).toBe(true); + }); + + it.each([ + ['a pending node that declares lockRecord: false', { requests: [{ ...PENDING, lock_record: false }], lock: false }], + ['CONTROL — no approval running', { requests: [], lock: false }], + ])('%s: the dialog is not locked', async (_label, approvals) => { + const fetchSpy = serveApprovals(approvals); + openAt('/apps/crm/crm_contract?form=r1'); + await waitFor(() => expect(lastModal()?.schema?.mode).toBe('edit')); + await waitFor(() => expect(approvalReads(fetchSpy).length).toBeGreaterThan(0)); + await new Promise((r) => setTimeout(r, 20)); + expect(lastModal().lockedBy).toBeUndefined(); + }); + + it('CONTROL — a create dialog asks nothing and is not locked', async () => { + const fetchSpy = serveApprovals({ requests: [], lock: true }); + openAt('/apps/crm/crm_contract?form=new'); + await waitFor(() => expect(lastModal()?.schema?.mode).toBe('create')); + await new Promise((r) => setTimeout(r, 20)); + expect(lastModal().lockedBy).toBeUndefined(); + expect(approvalReads(fetchSpy)).toEqual([]); + }); +}); diff --git a/ui/packages/permissions/src/__tests__/RecordFieldAccessScope.rowWritable-27138.test.tsx b/ui/packages/permissions/src/__tests__/RecordFieldAccessScope.rowWritable-27138.test.tsx new file mode 100644 index 00000000000..34e732aca93 --- /dev/null +++ b/ui/packages/permissions/src/__tests__/RecordFieldAccessScope.rowWritable-27138.test.tsx @@ -0,0 +1,151 @@ +/** + * 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. + */ + +/** + * objectstack#27138 — `RecordFieldAccessScope` answers the row's own write + * verdict: the projection's `writable` (the write gate's judgement of sharing, + * ownership, row-level security and the object grant on the stored row), and + * a lock the host reads that the projection leaves out (`lockedBy: 'approval'`). + * + * Under a signed-in answer that grants `rfa_contract` edit: + * - `writable: false` → every field of the record refuses `write` (the ones + * `editable` lists included) and `check('rfa_contract', 'update')` refuses + * with the reason `record-not-writable`; + * - `read`, `create`, other objects and a `check` naming another record keep + * the enclosing answer; + * - CONTROL `writable: true` and CONTROL no `writable` key → as before: the + * fields `editable` lists are writable and `update` is allowed; + * - `lockedBy: 'approval'` → refused with `record-locked-by-approval`, before + * any answer and with none; + * - an object grant the enclosing provider already refuses keeps its reason; + * - a scope nested under one for the same record adds the host's lock without + * asking again. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import React from 'react'; +import { MePermissionsProvider, type MePermissionsResponse } from '../MePermissionsProvider'; +import { RecordFieldAccessScope } from '../RecordFieldAccessScope'; +import { usePermissions } from '../usePermissions'; + +type Perms = ReturnType; + +function Probe({ seen }: { seen: Perms[] }) { + seen.push(usePermissions()); + return null; +} + +const ANSWER: MePermissionsResponse = { + authenticated: true, + userId: 'u1', + tenantId: 'org_1', + roles: ['member'], + permissionSets: ['rfa_fixture_member'], + objects: { + rfa_contract: { allowRead: true, allowCreate: true, allowEdit: true }, + invoice: { allowRead: true, allowEdit: true }, + }, + fields: {}, +}; + +const READ_ONLY_GRANT: MePermissionsResponse = { + ...ANSWER, + objects: { ...ANSWER.objects, rfa_contract: { allowRead: true, allowCreate: true, allowEdit: false } }, +}; + +const PROJECTION = { editable: ['title', 'summary'] }; + +function mount( + load: (o: string, id: string) => Promise, + { lockedBy, principal = ANSWER, nestedLock = false }: { + lockedBy?: 'approval'; + principal?: MePermissionsResponse; + nestedLock?: boolean; + } = {}, +) { + const seen: Perms[] = []; + const probe = ; + render( + + + {nestedLock + ? {probe} + : probe} + + , + ); + return { last: () => seen[seen.length - 1] }; +} + +describe('[#27138] RecordFieldAccessScope — the row verdict', () => { + it('on writable: false no field of the record is writable and its update is refused', async () => { + const load = vi.fn(async () => ({ ...PROJECTION, writable: false })); + const { last } = mount(load); + await waitFor(() => expect(last().check('rfa_contract', 'update').allowed).toBe(false)); + expect(last().check('rfa_contract', 'update')).toEqual({ allowed: false, reason: 'record-not-writable' }); + expect(last().can('rfa_contract', 'update')).toBe(false); + expect(last().checkField('rfa_contract', 'title', 'write')).toBe(false); + expect(last().checkField('RFA_Contract', 'summary', 'write')).toBe(false); + }); + + it('only the update of this row is refused: read, create, other objects and other records keep the enclosing answer', async () => { + const { last } = mount(async () => ({ ...PROJECTION, writable: false })); + await waitFor(() => expect(last().can('rfa_contract', 'update')).toBe(false)); + expect(last().checkField('rfa_contract', 'title', 'read')).toBe(true); + expect(last().checkField('rfa_contract', 'title', 'create')).toBe(true); + expect(last().can('rfa_contract', 'read')).toBe(true); + expect(last().can('rfa_contract', 'create')).toBe(true); + expect(last().can('invoice', 'update')).toBe(true); + expect(last().checkField('invoice', 'title', 'write')).toBe(true); + expect(last().check('rfa_contract', 'update', { id: 'c2' }).allowed).toBe(true); + expect(last().check('rfa_contract', 'update', { id: 'c1' }).allowed).toBe(false); + }); + + it.each([ + ['writable: true', { ...PROJECTION, writable: true }], + ['no writable key (a server that predates the verdict)', PROJECTION], + ])('CONTROL — %s leaves the row writable and the fields as the projection lists them', async (_label, answer) => { + const load = vi.fn(async () => answer); + const { last } = mount(load); + await waitFor(() => expect(load).toHaveBeenCalled()); + await new Promise((r) => setTimeout(r, 0)); + expect(last().check('rfa_contract', 'update')).toEqual({ allowed: true, reason: undefined }); + expect(last().checkField('rfa_contract', 'title', 'write')).toBe(true); + expect(last().checkField('rfa_contract', 'summary', 'write')).toBe(true); + }); + + it("a host's approval lock refuses the row before any answer, and with none", async () => { + let resolve: ((v: any) => void) | undefined; + const pending = mount(() => new Promise((r) => { resolve = r; }), { lockedBy: 'approval' }); + await waitFor(() => expect(resolve).toBeTypeOf('function')); + expect(pending.last().check('rfa_contract', 'update')).toEqual({ allowed: false, reason: 'record-locked-by-approval' }); + expect(pending.last().checkField('rfa_contract', 'title', 'write')).toBe(false); + resolve!(null); + await new Promise((r) => setTimeout(r, 0)); + expect(pending.last().check('rfa_contract', 'update').reason).toBe('record-locked-by-approval'); + + const writable = mount(async () => ({ ...PROJECTION, writable: true }), { lockedBy: 'approval' }); + await new Promise((r) => setTimeout(r, 0)); + expect(writable.last().check('rfa_contract', 'update').reason).toBe('record-locked-by-approval'); + }); + + it('an object grant the enclosing provider refuses keeps its own reason', async () => { + const { last } = mount(async () => ({ ...PROJECTION, writable: false }), { principal: READ_ONLY_GRANT }); + await new Promise((r) => setTimeout(r, 0)); + await waitFor(() => expect(last().checkField('rfa_contract', 'title', 'write')).toBe(false)); + expect(last().check('rfa_contract', 'update')).toEqual({ allowed: false, reason: 'denied-by-permission-set' }); + }); + + it('a nested scope for the same record adds the host lock without asking again', async () => { + const load = vi.fn(async () => ({ ...PROJECTION, writable: true })); + const { last } = mount(load, { nestedLock: true }); + await waitFor(() => expect(last().check('rfa_contract', 'update').reason).toBe('record-locked-by-approval')); + expect(load).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/packages/plugin-detail/src/renderers/__tests__/record-blocks.rowWritable-27138.test.tsx b/ui/packages/plugin-detail/src/renderers/__tests__/record-blocks.rowWritable-27138.test.tsx new file mode 100644 index 00000000000..05dee4e3cbf --- /dev/null +++ b/ui/packages/plugin-detail/src/renderers/__tests__/record-blocks.rowWritable-27138.test.tsx @@ -0,0 +1,169 @@ +/** + * 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. + */ + +/** + * objectstack#27138 — the record page's in-place edit reads the row's own + * write verdict from the same record projection the edit dialog reads. + * + * The persona may edit `rfa_contract` (the object grant allows edit) and the + * projection lists `region` and `summary` as editable at field level; the + * record is a row shared to them for reading only, so the server answers + * `writable: false`. Rows, for `record:details` and for `record:highlights`: + * - `writable: false` → no field edits in place; + * - CONTROL `writable: true` → the `editable` fields edit in place, as before. + */ +import * as React from 'react'; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, act, cleanup, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { HighlightFieldsProvider, InlineEditProvider, RecordContextProvider, useInlineEdit } from '@object-ui/react'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; +import { RecordDetailsRenderer } from '../record-details'; +import { RecordHighlightsRenderer } from '../record-highlights'; + +const OBJECT = 'rfa_contract'; +const FIELDS = ['region', 'summary', 'ref_no']; + +const OBJECT_SCHEMA = { + name: OBJECT, + label: 'Contract', + // The title field the details body draws in the header, not as a row. + nameField: 'name', + highlightFields: FIELDS, + fields: { + name: { type: 'text', label: 'Name' }, + region: { type: 'text', label: 'Region' }, + summary: { type: 'text', label: 'Summary' }, + ref_no: { type: 'text', label: 'Reference number' }, + }, +}; + +const PRINCIPAL: MePermissionsResponse = { + authenticated: true, + userId: 'u-1', + tenantId: null, + roles: ['finance'], + permissionSets: ['rfa_fixture_member'], + objects: { [OBJECT]: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false } }, + // As `/auth/me/permissions` serves them: the conditioned entry as its lock. + fields: { + [`${OBJECT}.summary`]: { readable: true, editable: false }, + [`${OBJECT}.ref_no`]: { readable: true, editable: false }, + }, +} as MePermissionsResponse; + +const RECORD = { id: 'c1', name: 'MSA', region: 'EMEA', summary: 'Draft terms', ref_no: 'R-1' }; + +const ACCESS = { + notWritable: { object: OBJECT, recordId: 'c1', readable: ['id', ...FIELDS], editable: ['region', 'summary'], writable: false }, + writable: { object: OBJECT, recordId: 'c1', readable: ['id', ...FIELDS], editable: ['region', 'summary'], writable: true }, +}; + +function makeDS(access: (typeof ACCESS)[keyof typeof ACCESS], withMethod = true) { + const listeners = new Set<(e: any) => void>(); + return { + getObjectSchema: vi.fn(async () => OBJECT_SCHEMA), + findOne: vi.fn(async () => ({ ...RECORD })), + find: vi.fn(async () => ({ data: [] })), + update: vi.fn(async (resource: string, id: string, data: any) => { + const record = { ...RECORD, ...data }; + // As the adapter does: every successful write is reported to `onMutation` subscribers. + for (const listener of listeners) listener({ type: 'update', resource, id, record }); + return record; + }), + onMutation: (listener: (e: any) => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + ...(withMethod ? { getRecordFieldAccess: vi.fn(async () => access) } : {}), + }; +} + +/** Opens the page's inline-edit session, as the pencil does. */ +function EnterEdit() { + const inline = useInlineEdit(); + return ; +} + +const BLOCKS = [ + { block: 'record:details', mount: () => }, + { block: 'record:highlights', mount: () => }, +]; + +function renderPage(ds: ReturnType, block: React.ReactNode, canEdit = true) { + return render( + + + + + + {block} + + + + , + ); +} + +/** With the session open: which fields draw an input (edit in place) rather than their read display. */ +function editable(): Record { + return { + region: screen.queryByDisplayValue(RECORD.region) !== null, + summary: screen.queryByDisplayValue(RECORD.summary) !== null, + ref_no: screen.queryByDisplayValue(RECORD.ref_no) !== null, + }; +} + +async function enterSession() { + await screen.findByText(RECORD.summary); + fireEvent.click(screen.getByText('enter-edit')); +} + +beforeAll(() => Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 })); + +beforeEach(() => { + // `useRecordEditable` probes `POST /api/v1/security/explain`; serve it from a + // double so no real socket opens. Its answer is orthogonal to the field locks. + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ allowed: true }), + text: async () => '{"allowed":true}', + })) as never); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe.each(BLOCKS)('[#27138] $block', ({ mount }) => { + it('on a row the caller may read but not write, no field edits in place', async () => { + const ds = makeDS(ACCESS.notWritable); + renderPage(ds, mount()); + await enterSession(); + await waitFor(() => expect(ds.getRecordFieldAccess).toHaveBeenCalledWith(OBJECT, 'c1')); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + await waitFor(() => expect(editable()).toEqual({ region: false, summary: false, ref_no: false })); + }); + + it('CONTROL — on a writable row the editable fields edit in place', async () => { + const ds = makeDS(ACCESS.writable); + renderPage(ds, mount()); + await enterSession(); + await waitFor(() => expect(ds.getRecordFieldAccess).toHaveBeenCalledWith(OBJECT, 'c1')); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + await waitFor(() => expect(editable()).toEqual({ region: true, summary: true, ref_no: false })); + }); +}); diff --git a/ui/packages/plugin-form/src/recordFieldAccess.rowWritable-27138.test.tsx b/ui/packages/plugin-form/src/recordFieldAccess.rowWritable-27138.test.tsx new file mode 100644 index 00000000000..7ab075419af --- /dev/null +++ b/ui/packages/plugin-form/src/recordFieldAccess.rowWritable-27138.test.tsx @@ -0,0 +1,165 @@ +/** + * 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. + */ + +/** + * objectstack#27138 — the record edit dialog asks the server whether THIS row + * is writable, and does not offer an edit the write gate refuses whole. + * + * The persona may read and edit `rfa_contract` (the object grant allows edit) + * and every field is editable at field level; the record is a `public_read` + * row shared to them for reading only, so the server's record projection + * answers `writable: false`. Before this card the dialog drew every field live + * and a Save the server refused as a whole. + * + * Rows, for the console's edit dialog (`ModalForm`, as `AppContent` mounts it) + * and for `DrawerForm`: + * - `writable: false` → every field read-only, the record's notice, no Save; + * - CONTROL `writable: true` → the `editable` fields stay live, Save offered; + * - CONTROL no `writable` key (an older server) → as before; + * - an object grant that refuses edit keeps the object's own notice. + * And for the dialog, the host's approval lock (`lockedBy: 'approval'`, what + * `AppContent` passes for a record a pending approval locks) → read-only, the + * record page's lock sentence, no Save — on a data source without the record + * projection too. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import React from 'react'; + +import { MePermissionsProvider } from '@object-ui/permissions'; +import { registerAllFields } from '@object-ui/fields'; +import { ModalForm } from './ModalForm'; +import { DrawerForm } from './DrawerForm'; + +registerAllFields(); +afterEach(cleanup); + +const FIELDS = { + title: { type: 'text', label: 'Title' }, + summary: { type: 'text', label: 'Summary' }, +}; + +const principal = (allowEdit: boolean): any => ({ + authenticated: true, + userId: 'u-1', + tenantId: null, + roles: ['member'], + permissionSets: ['rfa_fixture_member'], + objects: { rfa_contract: { allowCreate: true, allowRead: true, allowEdit, allowDelete: false } }, + fields: {}, +}); + +const RECORD = { id: 'c1', title: 'MSA', summary: 'Shared for reading' }; +const PROJECTION = { object: 'rfa_contract', recordId: 'c1', readable: ['id', 'title', 'summary'], editable: ['title', 'summary'] }; + +function makeDS(access: Record | null) { + return { + getObjectSchema: vi.fn().mockResolvedValue({ name: 'rfa_contract', label: 'Contract', fields: FIELDS }), + findOne: vi.fn().mockResolvedValue({ ...RECORD }), + find: vi.fn().mockResolvedValue({ data: [] }), + update: vi.fn().mockResolvedValue({ ...RECORD }), + ...(access ? { getRecordFieldAccess: vi.fn().mockResolvedValue(access) } : {}), + }; +} + +const schema = (formType: 'modal' | 'drawer') => ({ + type: 'object-form', + formType, + objectName: 'rfa_contract', + mode: 'edit', + recordId: 'c1', + fields: Object.keys(FIELDS), + open: true, +}) as any; + +const CONTAINERS: Array<{ container: string; mount: (ds: any, lockedBy?: 'approval') => React.ReactElement }> = [ + { + container: "the console's edit dialog (ModalForm)", + mount: (ds, lockedBy) => , + }, + { + container: 'the drawer (DrawerForm)', + mount: (ds) => , + }, +]; + +/** Every drawn field → `true` when it kept no enabled control. Read off `document.body`: the overlays portal out. */ +function locked(): Record { + const out: Record = {}; + for (const item of document.body.querySelectorAll('[data-field]')) { + const name = item.getAttribute('data-field') as string; + out[name] = item.querySelector('input:not([disabled]), textarea:not([disabled])') === null; + } + return out; +} + +const saveButtons = () => document.body.querySelectorAll('button[type="submit"]').length; +const notice = () => document.body.querySelector('[data-testid="closed-affordance-notice"]'); + +async function drawn() { + await waitFor(() => { + const el = document.querySelector('input[name="title"]') as HTMLInputElement | null; + if (!el || el.value !== 'MSA') throw new Error('record not on screen yet'); + }); +} + +describe.each(CONTAINERS)('[#27138] $container', ({ mount }) => { + it('on a row the caller may read but not write, the record is drawn read-only with its reason and no Save', async () => { + const ds = makeDS({ ...PROJECTION, writable: false }); + render({mount(ds)}); + await drawn(); + await waitFor(() => expect(locked()).toEqual({ title: true, summary: true })); + expect(ds.getRecordFieldAccess).toHaveBeenCalledWith('rfa_contract', 'c1'); + expect(notice()?.getAttribute('data-affordance')).toBe('record'); + expect(notice()?.textContent).toBe("You don't have permission to edit this record. The fields are read-only."); + expect(saveButtons()).toBe(0); + }); + + it.each([ + ['writable: true', { ...PROJECTION, writable: true }], + ['no writable key (an older server)', PROJECTION], + ])('CONTROL — %s keeps the editable fields live and offers Save', async (_label, access) => { + const ds = makeDS(access); + render({mount(ds)}); + await drawn(); + await waitFor(() => expect(ds.getRecordFieldAccess).toHaveBeenCalled()); + await new Promise((r) => setTimeout(r, 0)); + expect(locked()).toEqual({ title: false, summary: false }); + expect(notice()).toBeNull(); + expect(saveButtons()).toBe(1); + }); + + it("an object grant that refuses edit keeps the object's own notice", async () => { + const ds = makeDS({ ...PROJECTION, writable: false }); + render({mount(ds)}); + await drawn(); + await waitFor(() => expect(ds.getRecordFieldAccess).toHaveBeenCalled()); + await new Promise((r) => setTimeout(r, 0)); + expect(locked()).toEqual({ title: true, summary: true }); + expect(notice()?.getAttribute('data-affordance')).toBe('edit'); + }); +}); + +describe("[#27138] the console's edit dialog under the host's approval lock", () => { + it.each([ + ['a writable row', { ...PROJECTION, writable: true }], + ['a data source without the record projection', null], + ])('on %s, a record a pending approval locks is drawn read-only with the lock sentence and no Save', async (_label, access) => { + const ds = makeDS(access); + render( + + + , + ); + await drawn(); + await waitFor(() => expect(locked()).toEqual({ title: true, summary: true })); + expect(notice()?.getAttribute('data-affordance')).toBe('approval'); + expect(notice()?.textContent).toBe('This record has a pending approval request; editing is locked'); + expect(saveButtons()).toBe(0); + }); +}); From 695f5164c033e6186df23f455c1d2b8fff7bc45c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 15:41:20 +0000 Subject: [PATCH 3/4] test(app-shell): serve the edit dialog's approvals read from a file-wide double Claude-Session: https://claude.ai/code/session_01JQwxbYqMqySPCeFRdqDyHp Co-Authored-By: Claude --- .../AppContent.createSeed-12291.test.tsx | 12 ++++- ...tent.editDialogApprovalLock-27138.test.tsx | 45 ++++++++++--------- .../AppContent.formViewChrome-26843.test.tsx | 12 ++++- ...ppContent.formViewModalSize-12803.test.tsx | 12 ++++- ...nt.recordDialogInterimBound-12521.test.tsx | 12 ++++- ....recordSaveToastNamesRecord-26172.test.tsx | 12 ++++- 6 files changed, 80 insertions(+), 25 deletions(-) diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.createSeed-12291.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.createSeed-12291.test.tsx index 9cf1ff63bf6..7508d245203 100644 --- a/ui/packages/app-shell/src/console/__tests__/AppContent.createSeed-12291.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.createSeed-12291.test.tsx @@ -34,7 +34,7 @@ */ import '@testing-library/jest-dom/vitest'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; import React from 'react'; import { render, cleanup, waitFor, screen, fireEvent } from '@testing-library/react'; import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; @@ -155,6 +155,16 @@ vi.mock('@object-ui/react', async (importOriginal) => ({ useMutationInvalidationBridge: () => {}, })); +// objectstack#27138 — an edit dialog reads the approvals state of the record it +// edits (`useRecordApprovals`). No approval runs in this file: the approvals +// routes answer empty from one double, installed for the whole file so no read +// can reach a socket after a test body returns, and handed back in `afterAll`. +vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, +}))); +afterAll(() => vi.unstubAllGlobals()); + import { AppContent } from '../AppContent'; function LocationProbe() { diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.editDialogApprovalLock-27138.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.editDialogApprovalLock-27138.test.tsx index 03da39e8a99..07c09bcfa62 100644 --- a/ui/packages/app-shell/src/console/__tests__/AppContent.editDialogApprovalLock-27138.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.editDialogApprovalLock-27138.test.tsx @@ -26,7 +26,7 @@ */ import '@testing-library/jest-dom/vitest'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; import React from 'react'; import { render, cleanup, waitFor, screen } from '@testing-library/react'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; @@ -131,24 +131,32 @@ import { __resetRecordApprovalsStore } from '../../hooks/useRecordApprovals'; type Approvals = { requests: any[]; lock: boolean }; -function serveApprovals({ requests, lock }: Approvals) { - const fetchSpy = vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); - const body = url.includes('/approvals/records/') - ? { success: true, data: { locked: lock } } - : url.includes('/approvals/requests?') - ? { data: requests } - : url.includes('/approvals/requests/') - ? { data: requests[0] } - : {}; - return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); - }); - vi.stubGlobal('fetch', fetchSpy); +/** What the approvals routes answer; set per test. */ +let served: Approvals = { requests: [], lock: false }; + +// Installed once for the whole file, so no read the dialog issues after a +// test body returns can reach a socket; handed back in `afterAll`. +const fetchSpy = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const body = url.includes('/approvals/records/') + ? { success: true, data: { locked: served.lock } } + : url.includes('/approvals/requests?') + ? { data: served.requests } + : url.includes('/approvals/requests/') + ? { data: served.requests[0] } + : {}; + return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); +}); +vi.stubGlobal('fetch', fetchSpy); +afterAll(() => vi.unstubAllGlobals()); + +function serveApprovals(approvals: Approvals) { + served = approvals; return fetchSpy; } -const approvalReads = (fetchSpy: ReturnType) => - fetchSpy.mock.calls.map(([u]) => String(u)).filter((u) => u.includes('/approvals/')); +const approvalReads = (spy: typeof fetchSpy) => + spy.mock.calls.map(([u]) => String(u)).filter((u) => u.includes('/approvals/')); function openAt(url: string) { render( @@ -169,10 +177,7 @@ beforeEach(() => { __resetRecordApprovalsStore(); vi.clearAllMocks(); }); -afterEach(() => { - cleanup(); - vi.unstubAllGlobals(); -}); +afterEach(cleanup); describe('[#27138] the console edit dialog reads the approvals record lock', () => { it.each([ diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.formViewChrome-26843.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.formViewChrome-26843.test.tsx index 1b80c739d73..cfcf8893e9f 100644 --- a/ui/packages/app-shell/src/console/__tests__/AppContent.formViewChrome-26843.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.formViewChrome-26843.test.tsx @@ -19,7 +19,7 @@ */ import '@testing-library/jest-dom/vitest'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; import React from 'react'; import { render, cleanup, waitFor, screen } from '@testing-library/react'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; @@ -143,6 +143,16 @@ vi.mock('@object-ui/react', async (importOriginal) => ({ useMutationInvalidationBridge: () => {}, })); +// objectstack#27138 — an edit dialog reads the approvals state of the record it +// edits (`useRecordApprovals`). No approval runs in this file: the approvals +// routes answer empty from one double, installed for the whole file so no read +// can reach a socket after a test body returns, and handed back in `afterAll`. +vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, +}))); +afterAll(() => vi.unstubAllGlobals()); + import { AppContent } from '../AppContent'; const lastModal = () => probe.modalSchemas[probe.modalSchemas.length - 1]; diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.formViewModalSize-12803.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.formViewModalSize-12803.test.tsx index 8a2c860ec27..b2d24613c2c 100644 --- a/ui/packages/app-shell/src/console/__tests__/AppContent.formViewModalSize-12803.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.formViewModalSize-12803.test.tsx @@ -21,7 +21,7 @@ */ import '@testing-library/jest-dom/vitest'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; import React from 'react'; import { render, cleanup, waitFor, screen } from '@testing-library/react'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; @@ -141,6 +141,16 @@ vi.mock('@object-ui/react', async (importOriginal) => ({ useMutationInvalidationBridge: () => {}, })); +// objectstack#27138 — an edit dialog reads the approvals state of the record it +// edits (`useRecordApprovals`). No approval runs in this file: the approvals +// routes answer empty from one double, installed for the whole file so no read +// can reach a socket after a test body returns, and handed back in `afterAll`. +vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, +}))); +afterAll(() => vi.unstubAllGlobals()); + import { AppContent } from '../AppContent'; /** Open the record dialog at `url` and return the schema it was handed. */ diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.recordDialogInterimBound-12521.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.recordDialogInterimBound-12521.test.tsx index 61d0aea6339..d1cdfe2227b 100644 --- a/ui/packages/app-shell/src/console/__tests__/AppContent.recordDialogInterimBound-12521.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.recordDialogInterimBound-12521.test.tsx @@ -30,7 +30,7 @@ */ import '@testing-library/jest-dom/vitest'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; import React from 'react'; import { render, cleanup, waitFor, screen } from '@testing-library/react'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; @@ -142,6 +142,16 @@ vi.mock('@object-ui/react', async (importOriginal) => ({ useMutationInvalidationBridge: () => {}, })); +// objectstack#27138 — an edit dialog reads the approvals state of the record it +// edits (`useRecordApprovals`). No approval runs in this file: the approvals +// routes answer empty from one double, installed for the whole file so no read +// can reach a socket after a test body returns, and handed back in `afterAll`. +vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, +}))); +afterAll(() => vi.unstubAllGlobals()); + import { AppContent } from '../AppContent'; /** The URL that opens each task flow on the heavy object. */ diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.recordSaveToastNamesRecord-26172.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.recordSaveToastNamesRecord-26172.test.tsx index 8a9b6fad44f..17cc5fb4e62 100644 --- a/ui/packages/app-shell/src/console/__tests__/AppContent.recordSaveToastNamesRecord-26172.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.recordSaveToastNamesRecord-26172.test.tsx @@ -27,7 +27,7 @@ */ import '@testing-library/jest-dom/vitest'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; import React from 'react'; import { render, cleanup, waitFor } from '@testing-library/react'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; @@ -126,6 +126,16 @@ vi.mock('@object-ui/react', async (importOriginal) => ({ useMutationInvalidationBridge: () => {}, })); +// objectstack#27138 — an edit dialog reads the approvals state of the record it +// edits (`useRecordApprovals`). No approval runs in this file: the approvals +// routes answer empty from one double, installed for the whole file so no read +// can reach a socket after a test body returns, and handed back in `afterAll`. +vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, +}))); +afterAll(() => vi.unstubAllGlobals()); + import { AppContent } from '../AppContent'; type Lang = 'en' | 'zh-CN'; From e97571a938431e57c655573e14c39da883dcac5f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 16:33:14 +0000 Subject: [PATCH 4/4] test(app-shell): drop the unused screen import in the edit-dialog approval-lock test Lint UI refused it as no-unused-vars. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01JQwxbYqMqySPCeFRdqDyHp --- .../__tests__/AppContent.editDialogApprovalLock-27138.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.editDialogApprovalLock-27138.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.editDialogApprovalLock-27138.test.tsx index 07c09bcfa62..a7824116e9b 100644 --- a/ui/packages/app-shell/src/console/__tests__/AppContent.editDialogApprovalLock-27138.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.editDialogApprovalLock-27138.test.tsx @@ -28,7 +28,7 @@ import '@testing-library/jest-dom/vitest'; import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; import React from 'react'; -import { render, cleanup, waitFor, screen } from '@testing-library/react'; +import { render, cleanup, waitFor } from '@testing-library/react'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; import { registerAllFields } from '@object-ui/fields';