Skip to content

Commit b5e4ce6

Browse files
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 (#27313)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8DBY9GiYPjaHy3dgnowAq6D)_ Fixes #27138 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:记录编辑对话框读取服务端对“这一行”的 `writable` 判决与审批锁;不可写的行整表只读、说明原因、不显示保存。 - 为什么改:只读共享或审批锁定的记录,对话框仍可编辑,填完点保存被服务端整单拒绝。 - 风险与回滚:仅在 `writable: false` 或审批锁时收紧;缺 `writable` 的旧服务端行为不变;回滚即 revert 本 PR。 - 先看:`ui/packages/permissions/src/RecordFieldAccessScope.tsx`(`refusedRow`)、`ui/packages/plugin-form/src/fieldWriteGate.ts`(`closedFormAffordance`)、`ui/packages/app-shell/src/console/AppContent.tsx`(`formApprovals`)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-form/src/recordFieldAccess.rowWritable-27138.test.tsx`,预期 10 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes The record edit surfaces now ask the server whether THIS row is writable, and read the approvals record lock the projection leaves out. No client-side re-derivation of sharing (ruling 6457467702, letter B). - **Type.** `RecordFieldAccess` (`@object-ui/types`) gains `writable?: boolean`, the record projection's row verdict (spec `GetRecordFieldAccessResponseSchema`, landed by PR #27232). `ObjectStackAdapter.getRecordFieldAccess` already returns the client's answer untouched, so the adapter is unchanged. - **Scope.** `RecordFieldAccessScope` (`@object-ui/permissions`): on `writable: false`, `refusedRow` answers `write` refused on every field of the record and `check(object, 'update')` refused with the reason `record-not-writable`. `read`, `create`, other objects and a `check` naming another record keep the enclosing answer; an object grant the provider already refuses keeps its own reason. A new `lockedBy: 'approval'` prop answers the same way with `record-locked-by-approval`, before any answer and on a data source without the projection. `writable: true` or an absent key changes nothing. A scope nested under one for the same record adds a host lock without asking again. - **Form.** Every gate below the scope reads it with no call-site change: `gateFormFields` (field locks plus the form-wide lock), the outbound `fieldWriteGate`, and the detail page's inline edit (`RecordFieldAccessForRecord`). `closedFormAffordance` reports `record` / `approval` for such a row, read off the scoped `check`'s reason. `ClosedAffordanceNotice` says "You don't have permission to edit this record. The fields are read-only." (new key `form.noPermissionToEditRecord`, all ten packs) or the record page's approval sentence (`detail.lockedTooltip`, reused). `ModalForm` and `DrawerForm` offer no Save on it (`closedForRecord`). `ModalForm` takes `lockedBy` as a React prop, and `RecordFieldAccessForForm` forwards it. - **Approval lock in the console's edit dialog.** `AppContent` reads the approvals state of the record the overlay edits with `useRecordApprovals` (the record page's read, joined on the wire when the page already asked) and the page's rule. The rule's input is now one helper, `openApprovalOf`, used by both `RecordDetailView` and `AppContent`, so the dialog cannot offer an edit the page locks. The record page's Edit action already read the lock (disabled while approval-locked) and is unchanged. - `ObjectForm.tsx` is untouched (serial constraint with #27269 / #27159). ## Why On a `public_read` row the caller may read but not write, the edit dialog drew every field live and the server refused the save whole. That breaks "a client never advertises an edit the gate refuses" (`permission.zod.ts`). Seat note 6468378207: `writable` does not cover the approvals record lock, so a row under approval must read that lock too. ## Tests - `ui/packages/permissions/src/__tests__/RecordFieldAccessScope.rowWritable-27138.test.tsx` (7 tests). - `ui/packages/plugin-form/src/recordFieldAccess.rowWritable-27138.test.tsx` (10 tests, `ModalForm` and `DrawerForm`): `writable: false` gives read-only fields, the record notice and no Save. CONTROL `writable: true` and CONTROL no key keep the fields live and Save offered. An object grant refusal keeps the object notice. The dialog under `lockedBy: 'approval'` (writable row, and a data source without the projection) is read-only with the lock sentence and no Save. - `ui/packages/plugin-detail/src/renderers/__tests__/record-blocks.rowWritable-27138.test.tsx` (4 tests): `record:details` / `record:highlights` offer no in-place editor on `writable: false`; CONTROL writable. - `ui/packages/app-shell/src/console/__tests__/AppContent.editDialogApprovalLock-27138.test.tsx`: a real `AppContent` mount hands `ModalForm` `lockedBy: 'approval'` for a reader outside the slate (record lock answers) and for a participant on a locking node. It hands nothing for a `lockRecord: false` node, nothing when no approval runs, and nothing (with no approvals read) on a create dialog. ## Acceptance notes - The record page's Edit button still opens the dialog on a `writable: false` row; the dialog explains and offers no Save. Disabling the button itself would need the page host to fetch the projection, which no block above it does today. - The master-detail arm (`subforms`) of `ModalForm` / `DrawerForm` draws its Save bar through `ObjectForm` / `MasterDetailForm`, which this PR does not touch: its fields lock, its own Save bar stays. - `DrawerForm` reads `writable` but takes no host approval lock: no host renders it with a known lock today. - `content/docs/permissions/field-level-security.mdx` already states the `writable` contract ("a client need not offer the edit at all"); it is outside the claimed file surface and unchanged. ## Verification All readings on head `695f5164` (main merged at `5b5cff15`). - New tests: scope 7/7, dialog and drawer 10/10, inline edit 4/4, `AppContent` approval lock 5/5. - Affected suites (lock run, `pnpm --dir ui exec vitest run packages/permissions/ packages/plugin-form/ packages/i18n/ packages/plugin-detail/src/renderers/ packages/app-shell/src/console/ packages/app-shell/src/hooks/`): 705/710 files green on the first run. The 5 red files were older `AppContent` edit-dialog tests whose dialog now issues the approvals read with no fetch double (the network-escape guard). Each now installs one file-wide approvals double; `AppContent` re-run 36/36 files, 266/266 tests. `RecordDetailView` (the `openApprovalOf` refactor) 77/77 files, 512/512 tests. - Type-check: `@object-ui/types`, `permissions`, `i18n`, `plugin-form`, `plugin-detail` and `app-shell` `type-check` exit 0 after the closure builds. - Derived gates: `dispatch-gates --commands` derived 34 families, all exit 0 (`check-sdui-manifest` included); `--ran` reconciliation: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. `pnpm --dir ui exec vitest run scripts/` timed out twice (15 s) while the suite above held the box; re-run alone under the lock it was 174/174 files green. `check:type-check-debt` exit 3 (lock queue) on the first pass; it ran under the lock and reported OK. <details><summary>Ablations (one-shot, restored; blob == HEAD after each)</summary> `node scripts/ablation-replace.mjs` against `RecordFieldAccessScope.tsx` / `ModalForm.tsx`, tests resolved from `src` through the `ui/vitest.config.mts` alias, so no dist leg: - the `writable` read (`answer?.writable === false` to a constant `false`): 6 red, exactly the `writable: false` cases (scope 2, dialog/drawer 2, inline edit 2); the CONTROL cases and the approval cases stay green. - the host lock (`lockedBy === 'approval'` to a constant `false`): 4 red (scope 2, dialog 2). - the Save hiding (`!closedForRecord(closedAffordance)` to `true` in `ModalForm`): 3 red. An earlier attempt at the first one was a refused no-op: the replacement was a substring of the anchor, the tool refused it, and nothing ran. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` is the repo-wide run CI owns (`Lint UI`, `Lint & Repo Gates`). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbYqMqySPCeFRdqDyHp --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbYqMqySPCeFRdqDyHp)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7a95094 commit b5e4ce6

30 files changed

Lines changed: 1053 additions & 59 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/types': patch
3+
'@object-ui/permissions': patch
4+
'@object-ui/plugin-form': patch
5+
'@object-ui/i18n': patch
6+
---
7+
8+
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
9+
10+
Clause-②: no
11+
Metadata-protocol: none
12+
13+
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:
14+
15+
- **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.
16+
- **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.
17+
- **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.

‎ui/packages/app-shell/src/console/AppContent.tsx‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import { useAssistant } from '../assistant/assistantBus.js';
1414
import { ModalForm } from '@object-ui/plugin-form';
1515
import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components';
1616
import { toast } from 'sonner';
17-
import { useActionRunner, useGlobalUndo, useMutationInvalidationBridge, notifyDataChanged, FilterScopeProvider } from '@object-ui/react';
17+
import { useActionRunner, useGlobalUndo, useMutationInvalidationBridge, notifyDataChanged, FilterScopeProvider, resolveApprovalLock } from '@object-ui/react';
18+
import { useRecordApprovals, openApprovalOf } from '../hooks/useRecordApprovals.js';
1819
import { getRecordDisplayName } from '@object-ui/core';
1920
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
2021
import type { AppAccessVerdict, ConnectionState } from '@object-ui/data-objectstack';
@@ -829,6 +830,24 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
829830
: currentObjectDef;
830831
const isChildFormTask = !!formObjectParam && formObjectParam !== currentObjectDef?.name;
831832

833+
// objectstack#27138 — the record the overlay edits may be held by a pending
834+
// approval that locks it. The record's field projection leaves that lock out
835+
// (the server judges it on the values a save sends), so the dialog reads it
836+
// from the approvals read the record page reads, joined on the wire when the
837+
// page already asked, and through the same rule (`openApprovalOf` +
838+
// `resolveApprovalLock`): the dialog then draws the record read-only with
839+
// the page's lock sentence and no Save. The row has no `approval_status`
840+
// here (the overlay holds only its id), so an unanswered read locks nothing.
841+
const formApprovals = useRecordApprovals(
842+
editingRecord ? formObjectDef?.name : undefined,
843+
editingRecord?.id,
844+
);
845+
const formApprovalLocked = resolveApprovalLock({
846+
readAnswered: formApprovals.answered,
847+
openApproval: openApprovalOf(formApprovals),
848+
recordStatus: undefined,
849+
}).locked;
850+
832851
// objectstack-ai/objectstack#26172 — a record save names the record it wrote
833852
// (`ux-conventions.write-feedback`): `Task "Quarterly review" created`. The
834853
// name is the saved record's display name from the ONE ADR-0079 resolver,
@@ -1491,6 +1510,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
14911510
{formObjectDef && (
14921511
<ModalForm
14931512
key={`${formObjectDef.name}:${editingRecord?.id || 'new'}`}
1513+
lockedBy={formApprovalLocked ? 'approval' : undefined}
14941514
schema={{
14951515
type: 'object-form',
14961516
formType: 'modal',

‎ui/packages/app-shell/src/console/__tests__/AppContent.createSeed-12291.test.tsx‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
*/
3535

3636
import '@testing-library/jest-dom/vitest';
37-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
37+
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest';
3838
import React from 'react';
3939
import { render, cleanup, waitFor, screen, fireEvent } from '@testing-library/react';
4040
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
@@ -155,6 +155,16 @@ vi.mock('@object-ui/react', async (importOriginal) => ({
155155
useMutationInvalidationBridge: () => {},
156156
}));
157157

158+
// objectstack#27138 — an edit dialog reads the approvals state of the record it
159+
// edits (`useRecordApprovals`). No approval runs in this file: the approvals
160+
// routes answer empty from one double, installed for the whole file so no read
161+
// can reach a socket after a test body returns, and handed back in `afterAll`.
162+
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ data: [] }), {
163+
status: 200,
164+
headers: { 'Content-Type': 'application/json' },
165+
})));
166+
afterAll(() => vi.unstubAllGlobals());
167+
158168
import { AppContent } from '../AppContent';
159169

160170
function LocationProbe() {
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#27138 — the console's edit dialog reads the approvals record
11+
* lock of the record it edits, through the read and the rule the record page
12+
* uses (`useRecordApprovals`, `openApprovalOf`, `resolveApprovalLock`), and
13+
* hands it to `ModalForm` as `lockedBy: 'approval'`. The record's field
14+
* projection leaves that lock out, so without it the dialog offered fields
15+
* a save then refused.
16+
*
17+
* Measured from a real `AppContent` mount (the harness of
18+
* `AppContent.formViewModalSize-12803`), with the approvals routes served by a
19+
* fetch double:
20+
* - a reader outside the slate: the list answers empty and the record lock
21+
* answers `locked: true` → `lockedBy: 'approval'`;
22+
* - a participant: the pending request's node locks (`lock_record` absent)
23+
* → `'approval'`; it declares `lockRecord: false` → no lock;
24+
* - CONTROL no approval running → no lock;
25+
* - CONTROL a create dialog asks nothing and is not locked.
26+
*/
27+
28+
import '@testing-library/jest-dom/vitest';
29+
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest';
30+
import React from 'react';
31+
import { render, cleanup, waitFor } from '@testing-library/react';
32+
import { MemoryRouter, Routes, Route } from 'react-router-dom';
33+
import { registerAllFields } from '@object-ui/fields';
34+
35+
registerAllFields();
36+
37+
const probe = vi.hoisted(() => ({
38+
/** Every set of props the global record-form modal was rendered with. */
39+
modalProps: [] as any[],
40+
}));
41+
42+
vi.mock('@object-ui/plugin-form', async (importOriginal) => {
43+
const actual = await importOriginal<Record<string, any>>();
44+
return {
45+
...actual,
46+
ModalForm: (props: any) => {
47+
probe.modalProps.push(props);
48+
return <div data-testid="modal-form" />;
49+
},
50+
};
51+
});
52+
53+
vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({
54+
...(await importOriginal<typeof import('@object-ui/plugin-designer')>()),
55+
CreateAppPage: () => <div data-testid="create-app-page" />,
56+
EditAppPage: () => <div data-testid="edit-app-page" />,
57+
DashboardDesignPage: () => <div data-testid="dashboard-design-page" />,
58+
}));
59+
60+
vi.mock('../../layout/ConsoleLayout', () => ({
61+
ConsoleLayout: ({ children }: { children?: React.ReactNode }) => (
62+
<div data-testid="console-layout">{children}</div>
63+
),
64+
}));
65+
vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null }));
66+
vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null }));
67+
vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null }));
68+
vi.mock('../../views/ObjectView', () => ({ ObjectView: () => <div data-testid="object-view" /> }));
69+
vi.mock('../../views/RecordFormPage', () => ({
70+
RecordFormPage: () => <div data-testid="record-form-page" />,
71+
}));
72+
73+
vi.mock('@object-ui/auth', async (importOriginal) => ({
74+
...(await importOriginal<Record<string, unknown>>()),
75+
useAuth: () => ({
76+
user: { id: 'u1', name: 'Ada', email: 'ada@example.com', role: 'user' },
77+
getAuthConfig: async () => ({ features: {} }),
78+
activeOrganization: null,
79+
}),
80+
useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
81+
}));
82+
83+
const CONTRACT = {
84+
name: 'crm_contract',
85+
label: 'Contract',
86+
fields: { title: { type: 'text', label: 'Title' } },
87+
form: { type: 'modal' },
88+
};
89+
const OBJECTS = [CONTRACT];
90+
91+
const dataSourceStub = {
92+
onConnectionStateChange: () => () => {},
93+
getConnectionState: () => 'connected',
94+
getObjectSchema: vi.fn(async (name: string) => OBJECTS.find((o) => o.name === name)),
95+
findOne: vi.fn(async () => ({ id: 'r1' })),
96+
find: vi.fn(async () => ({ data: [] })),
97+
create: vi.fn(),
98+
update: vi.fn(),
99+
};
100+
vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({
101+
...(await importOriginal<Record<string, unknown>>()),
102+
useAdapter: () => dataSourceStub,
103+
}));
104+
105+
const APPS = [{ name: 'crm', label: 'CRM', isDefault: true, navigation: [] }];
106+
107+
// One value for the whole run: effects key on these members.
108+
const METADATA = {
109+
apps: APPS,
110+
objects: OBJECTS,
111+
loading: false,
112+
ensureType: undefined,
113+
error: null,
114+
refresh: async () => {},
115+
};
116+
vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({
117+
...(await importOriginal<Record<string, unknown>>()),
118+
useMetadata: () => METADATA,
119+
}));
120+
121+
const ACTION_RUNNER = { execute: () => {}, runner: { registerHandler: () => {}, getContext: () => ({}) } };
122+
vi.mock('@object-ui/react', async (importOriginal) => ({
123+
...(await importOriginal<Record<string, unknown>>()),
124+
useActionRunner: () => ACTION_RUNNER,
125+
useGlobalUndo: () => {},
126+
useMutationInvalidationBridge: () => {},
127+
}));
128+
129+
import { AppContent } from '../AppContent';
130+
import { __resetRecordApprovalsStore } from '../../hooks/useRecordApprovals';
131+
132+
type Approvals = { requests: any[]; lock: boolean };
133+
134+
/** What the approvals routes answer; set per test. */
135+
let served: Approvals = { requests: [], lock: false };
136+
137+
// Installed once for the whole file, so no read the dialog issues after a
138+
// test body returns can reach a socket; handed back in `afterAll`.
139+
const fetchSpy = vi.fn(async (input: RequestInfo | URL) => {
140+
const url = String(input);
141+
const body = url.includes('/approvals/records/')
142+
? { success: true, data: { locked: served.lock } }
143+
: url.includes('/approvals/requests?')
144+
? { data: served.requests }
145+
: url.includes('/approvals/requests/')
146+
? { data: served.requests[0] }
147+
: {};
148+
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
149+
});
150+
vi.stubGlobal('fetch', fetchSpy);
151+
afterAll(() => vi.unstubAllGlobals());
152+
153+
function serveApprovals(approvals: Approvals) {
154+
served = approvals;
155+
return fetchSpy;
156+
}
157+
158+
const approvalReads = (spy: typeof fetchSpy) =>
159+
spy.mock.calls.map(([u]) => String(u)).filter((u) => u.includes('/approvals/'));
160+
161+
function openAt(url: string) {
162+
render(
163+
<MemoryRouter initialEntries={[url]}>
164+
<Routes>
165+
<Route path="/apps/:appName/*" element={<AppContent />} />
166+
</Routes>
167+
</MemoryRouter>,
168+
);
169+
}
170+
171+
const lastModal = () => probe.modalProps[probe.modalProps.length - 1];
172+
173+
const PENDING = { id: 'req1', object_name: 'crm_contract', record_id: 'r1', status: 'pending' };
174+
175+
beforeEach(() => {
176+
probe.modalProps.length = 0;
177+
__resetRecordApprovalsStore();
178+
vi.clearAllMocks();
179+
});
180+
afterEach(cleanup);
181+
182+
describe('[#27138] the console edit dialog reads the approvals record lock', () => {
183+
it.each([
184+
['a reader outside the slate (the record lock answers)', { requests: [], lock: true }],
185+
['a participant whose pending node locks the record', { requests: [PENDING], lock: false }],
186+
])('%s: the dialog is handed the approval lock', async (_label, approvals) => {
187+
const fetchSpy = serveApprovals(approvals);
188+
openAt('/apps/crm/crm_contract?form=r1');
189+
await waitFor(() => expect(lastModal()?.schema?.mode).toBe('edit'));
190+
await waitFor(() => expect(lastModal().lockedBy).toBe('approval'));
191+
expect(approvalReads(fetchSpy).some((u) => u.includes('object=crm_contract') && u.includes('recordId=r1'))).toBe(true);
192+
});
193+
194+
it.each([
195+
['a pending node that declares lockRecord: false', { requests: [{ ...PENDING, lock_record: false }], lock: false }],
196+
['CONTROL — no approval running', { requests: [], lock: false }],
197+
])('%s: the dialog is not locked', async (_label, approvals) => {
198+
const fetchSpy = serveApprovals(approvals);
199+
openAt('/apps/crm/crm_contract?form=r1');
200+
await waitFor(() => expect(lastModal()?.schema?.mode).toBe('edit'));
201+
await waitFor(() => expect(approvalReads(fetchSpy).length).toBeGreaterThan(0));
202+
await new Promise((r) => setTimeout(r, 20));
203+
expect(lastModal().lockedBy).toBeUndefined();
204+
});
205+
206+
it('CONTROL — a create dialog asks nothing and is not locked', async () => {
207+
const fetchSpy = serveApprovals({ requests: [], lock: true });
208+
openAt('/apps/crm/crm_contract?form=new');
209+
await waitFor(() => expect(lastModal()?.schema?.mode).toBe('create'));
210+
await new Promise((r) => setTimeout(r, 20));
211+
expect(lastModal().lockedBy).toBeUndefined();
212+
expect(approvalReads(fetchSpy)).toEqual([]);
213+
});
214+
});

‎ui/packages/app-shell/src/console/__tests__/AppContent.formViewChrome-26843.test.tsx‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
*/
2020

2121
import '@testing-library/jest-dom/vitest';
22-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22+
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest';
2323
import React from 'react';
2424
import { render, cleanup, waitFor, screen } from '@testing-library/react';
2525
import { MemoryRouter, Routes, Route } from 'react-router-dom';
@@ -143,6 +143,16 @@ vi.mock('@object-ui/react', async (importOriginal) => ({
143143
useMutationInvalidationBridge: () => {},
144144
}));
145145

146+
// objectstack#27138 — an edit dialog reads the approvals state of the record it
147+
// edits (`useRecordApprovals`). No approval runs in this file: the approvals
148+
// routes answer empty from one double, installed for the whole file so no read
149+
// can reach a socket after a test body returns, and handed back in `afterAll`.
150+
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ data: [] }), {
151+
status: 200,
152+
headers: { 'Content-Type': 'application/json' },
153+
})));
154+
afterAll(() => vi.unstubAllGlobals());
155+
146156
import { AppContent } from '../AppContent';
147157

148158
const lastModal = () => probe.modalSchemas[probe.modalSchemas.length - 1];

‎ui/packages/app-shell/src/console/__tests__/AppContent.formViewModalSize-12803.test.tsx‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
*/
2222

2323
import '@testing-library/jest-dom/vitest';
24-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
24+
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest';
2525
import React from 'react';
2626
import { render, cleanup, waitFor, screen } from '@testing-library/react';
2727
import { MemoryRouter, Routes, Route } from 'react-router-dom';
@@ -141,6 +141,16 @@ vi.mock('@object-ui/react', async (importOriginal) => ({
141141
useMutationInvalidationBridge: () => {},
142142
}));
143143

144+
// objectstack#27138 — an edit dialog reads the approvals state of the record it
145+
// edits (`useRecordApprovals`). No approval runs in this file: the approvals
146+
// routes answer empty from one double, installed for the whole file so no read
147+
// can reach a socket after a test body returns, and handed back in `afterAll`.
148+
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ data: [] }), {
149+
status: 200,
150+
headers: { 'Content-Type': 'application/json' },
151+
})));
152+
afterAll(() => vi.unstubAllGlobals());
153+
144154
import { AppContent } from '../AppContent';
145155

146156
/** Open the record dialog at `url` and return the schema it was handed. */

‎ui/packages/app-shell/src/console/__tests__/AppContent.recordDialogInterimBound-12521.test.tsx‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
*/
3131

3232
import '@testing-library/jest-dom/vitest';
33-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
33+
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest';
3434
import React from 'react';
3535
import { render, cleanup, waitFor, screen } from '@testing-library/react';
3636
import { MemoryRouter, Routes, Route } from 'react-router-dom';
@@ -142,6 +142,16 @@ vi.mock('@object-ui/react', async (importOriginal) => ({
142142
useMutationInvalidationBridge: () => {},
143143
}));
144144

145+
// objectstack#27138 — an edit dialog reads the approvals state of the record it
146+
// edits (`useRecordApprovals`). No approval runs in this file: the approvals
147+
// routes answer empty from one double, installed for the whole file so no read
148+
// can reach a socket after a test body returns, and handed back in `afterAll`.
149+
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ data: [] }), {
150+
status: 200,
151+
headers: { 'Content-Type': 'application/json' },
152+
})));
153+
afterAll(() => vi.unstubAllGlobals());
154+
145155
import { AppContent } from '../AppContent';
146156

147157
/** The URL that opens each task flow on the heavy object. */

0 commit comments

Comments
 (0)