Skip to content

Commit 4049efa

Browse files
fix(app-shell): Studio Access refuses a new permission set over a taken identifier and can discard a set's draft (#26916)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbY2VZCDU1LZxstSD9mUKVAR)_ Fixes #26836 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:Studio「访问」页新建权限集前,先查已发布和草稿中的同名权限集(跨所有软件包);标识已被占用就拒绝、不写入,提示里点名那个权限集,属于本软件包时给出「打开它」。另外,打开的权限集若有待发布草稿,标题栏多一个「放弃草稿」。 - **为什么改**:原来的首写保护(`If-None-Match: *` + 草稿模式)只拦已有草稿,不拦已发布的同名权限集,于是新建会在线上权限集上叠一份空白草稿,发布软件包就会清空它的全部授权;Studio 里也没有办法放弃这份草稿。 - **风险与回滚**:低。只改 `AccessPillar` 一处的新建与放弃路径,沿用现有接口(列表、草稿列表、`DELETE ?state=draft&package=`);服务端、spec 不动。回滚即还原本 PR。 - **先看**:`ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx` 的 `AccessPillar`(`doCreate` 新增的占用检查、`discardDraft`、对话框 `extra` 里的拒绝提示);`StudioDesignSurface.accessCreateTaken-26836.test.tsx`。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/StudioDesignSurface.accessCreateTaken-26836.test.tsx`,预期 9 个测试全绿。 - **需要你做的**:无,席位落地。 ## What changes - **Create over a taken identifier is refused before anything is written.** `AccessPillar`'s `doCreate` now reads the published permission sets and the draft headers across every package (`client.list('permission')`, `client.listDrafts({ type: 'permission' })`, the same unscoped pair the Interfaces pillar's create reads). A name either holds is refused in the dialog with a sentence naming the set (its label and identifier). When the set is this package's (on the rail), the dialog offers "Open “LABEL”", which closes the dialog and opens it; a set another package holds gets no open path, and the sentence says it is outside this package. - **A fresh name is created as before**: the draft save with `mode: 'draft'`, `packageId`, `ifNoneMatch: '*'`, `surface: 'studio'`, the toast, and the new set opened. - **Discard draft.** An open set with a pending draft shows "Discard draft" in its header (beside Explain access), hidden on a read-only package. It confirms (one sentence for a published set, another for a set never published), then calls `client.reset('permission', NAME, { state: 'draft', packageId })`, the `DELETE ?state=draft&package=` door the metadata editor's discard uses. A published set goes back to its live body (the matrix re-reads it, as after a publish); a set never published leaves the rail. A refused discard is a toast with the door's message. - Six new `studio` namespace rows in `@object-ui/i18n`, `en` and `zh`. One root changeset: `@object-ui/app-shell` patch and `@object-ui/i18n` patch. - No `packages/spec/**`, no server change. ## Tests `StudioDesignSurface.accessCreateTaken-26836.test.tsx`, nine cases on the real `AccessPillar` and `PermissionMatrixEditPage`, over a client double that keeps published and draft rows per package and refuses a first-write pin over a draft row the way the door does: - create over a published id of this package: refused, `save` never called, no draft row, the sentence names the set, and "Open" opens it (its header and grant count); - create over a published id another package holds: refused, nothing written, no "Open"; - create over a draft id: refused before the save; - a draft that lands between the check and the save: the save still carries `ifNoneMatch: '*'` and the door's 409 is shown, the other draft untouched (objectui#11773); - a fresh id: saved with the four options above, toast, rail row and header; - discard on a published set: `reset` called with `{ state: 'draft', packageId }`, the matrix goes from 0 to 1 object granted (the live set), the set stays on the rail, the control goes away; - discard on a set never published: it leaves the rail and the remaining set opens; - a declined confirm discards nothing; - no discard control without a pending draft, nor on a read-only package. ## Acceptance notes - **The door half is not a server defect, so there is no `domain:server` child.** The metadata door does what the spec declares: `SaveMetaItemRequestSchema.parentVersion` says a present `null` "asserts 'no current row of this lifecycle'" (on the REST door, `If-None-Match: *`), and the `@objectstack/metadata-protocol` changelog entry that opened the header says `If-None-Match: *` "with `?mode=draft` for a draft … saves only where no row of that lifecycle exists". A draft-mode pin over a published row is therefore admitted by contract (it is how a client says "start a draft only if nobody has one"). Making the door judge every lifecycle would be a semantic change to that declared pin, beyond this card. - **Race window of the client-side check:** a set published (or created in another package) between the pillar's two reads and its save is not refused by the pin; the save then writes a blank draft over it, as before this change. A draft that lands in that window is still refused by the door (tested above). - The "Open" path exists only for a set on this package's rail; a set another package holds cannot be opened from this pillar, so the refusal there names it and asks for another identifier. ## Verification All at head `4dd29301`, in a worktree off `origin/main` `c4a4aefc`. <details><summary>New tests, ablation, type-check and nearby suites</summary> - `pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/StudioDesignSurface.accessCreateTaken-26836.test.tsx` under `scripts/pm/os-verify-lock.sh`: `Test Files 1 passed (1)`, `Tests 9 passed (9)`. - Ablation (`node scripts/ablation-replace.mjs`, anchor `if (publishedRow || draftRow) {` replaced by `if (false && (publishedRow || draftRow)) {`, anchor x1 to x0): the three refusal cases go red (`Tests 3 failed | 6 passed (9)`); restored, blob equal to HEAD and `git diff HEAD` empty. The tests import the pillar from source, so no build sits between the mutation and the run. - `pnpm --filter @object-ui/app-shell run type-check && pnpm --filter @object-ui/i18n run type-check` under the lock: `VERDICT command-exit 0` (app-shell's covers the test file). - Narrowed suites under the lock: every app-shell test file that names `AccessPillar`, the `studio` namespace or an `engine.studio.access` key (66 files), plus all of `packages/i18n/`: `Test Files 155 passed (155)`, `Tests 2159 passed | 13 skipped (2172)`. CI's Test UI runs the rest of app-shell. - `pnpm run check:i18n-designer-parity` (from `ui/`): "Every en row has a zh row, and every shared row carries the same placeholders." `pnpm check:new-line-citations` (from `ui/`): `0 new citation(s)`. </details> <details><summary>Derived dispatch gates (34/34)</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 34 commands; every one exited 0, the PR-body gates (`check-closing-keyword-parity`, `check-adr-0087-registration`, `check-changeset-no-major`) against this body. - `pnpm --dir ui exec vitest run scripts/` under the lock: `Test Files 178 passed | 2 skipped (180)`, `Tests 5065 passed | 2 skipped (5067)`. - `pnpm check:type-check-debt` under the lock: `0 raw tsc error(s) total, none above its recorded number`. - `dispatch-gates --ran`: `34 derived famil(ies) accounted for — 34 run, 0 NOT-MEASURED`. </details> Dogfood battery: not run, no `!` in the title and no `(narrowing)` arm. --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jRu5CpdGJFtUFzPvUy)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c90b5b1 commit 4049efa

5 files changed

Lines changed: 491 additions & 6 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
fix(app-shell): Studio Access refuses a new permission set whose identifier a set already holds, and can discard a set's pending draft
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **Before.** The Access pillar's "+ New" saved the blank skeleton as a draft under the first-write pin (`If-None-Match: *` with `?mode=draft`), which the door refuses only where a DRAFT row exists. A name that derived to a published set's identifier therefore wrote a blank draft over the live set: the rail kept showing the set, its matrix opened empty, and publishing the package would have replaced every grant of the set. Studio offered no way to remove that draft.
12+
- **Now.** Before anything is written, the create reads the published and the draft permission sets across every package. A name one of them holds is refused in the dialog with a sentence naming the set; when the set is this package's, the dialog offers to open it instead. A fresh name is created as before, and the door's first-write pin still refuses a draft that lands between the check and the save.
13+
- **Discard.** An open set with a pending draft shows "Discard draft" in its header. It confirms, then deletes the draft at this package's address (`DELETE ?state=draft&package=…`, the metadata editor's discard door): a published set goes back to its live body, and a set that was never published leaves the rail.
14+
- **Strings** (`@object-ui/i18n`). Six new rows in the `studio` namespace, `en` and `zh`.
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26836 — the Access pillar's "+ New" over an identifier a
5+
* permission set already holds, and the discard of a set's pending draft.
6+
*
7+
* The create's first-write pin (`If-None-Match: *` with `?mode=draft`)
8+
* asserts that no DRAFT row exists, so a name that derived to a PUBLISHED
9+
* set's identifier wrote a blank draft over the live set, and publishing the
10+
* package would have replaced its grants. Studio offered no way to remove that
11+
* draft.
12+
*
13+
* - Create over a published id (this package's): refused, nothing written,
14+
* the sentence names the set, and "Open" opens it.
15+
* - Create over a published id another package holds: refused, nothing
16+
* written, and no "Open".
17+
* - Create over a draft id: refused before the save, and the save still
18+
* carries the first-write pin for a draft that lands after the check.
19+
* - A fresh id: created as before.
20+
* - Discard: a published set's draft goes and the matrix reads the live
21+
* set again; a never-published set leaves the rail.
22+
*
23+
* The REAL `AccessPillar` and `PermissionMatrixEditPage`, over a client that
24+
* keeps published and draft rows the way the metadata door does.
25+
*/
26+
27+
import '@testing-library/jest-dom/vitest';
28+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
29+
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
30+
import { MemoryRouter } from 'react-router-dom';
31+
32+
const PKG = 'com.acme.app';
33+
const OTHER_PKG = 'com.other.app';
34+
35+
type Row = { pkg: string; body: Record<string, unknown> };
36+
37+
let clientImpl: ReturnType<typeof makeClient>;
38+
39+
vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
40+
const actual = await importOriginal<Record<string, unknown>>();
41+
return {
42+
...actual,
43+
useMetadataClient: () => clientImpl,
44+
useMetadataTypes: () => ({
45+
loading: false,
46+
error: null,
47+
entries: [{ type: 'permission', label: 'Permission', allowEnvironmentOverlay: true }],
48+
}),
49+
};
50+
});
51+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
52+
vi.mock('../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPanel: () => null }));
53+
vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: () => null }));
54+
vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null }));
55+
56+
import { toast } from 'sonner';
57+
import { t, tFormat } from '../metadata-admin/i18n';
58+
import { AccessPillar } from './StudioDesignSurface';
59+
60+
window.matchMedia = ((query: string) => ({
61+
matches: false,
62+
media: query,
63+
onchange: null,
64+
addEventListener: () => {},
65+
removeEventListener: () => {},
66+
addListener: () => {},
67+
removeListener: () => {},
68+
dispatchEvent: () => false,
69+
})) as unknown as typeof window.matchMedia;
70+
71+
const en = (key: string) => t(key, 'en');
72+
73+
const EDITOR = { name: 'project_editor', label: 'Project Editor', objects: { crm_lead: { allowRead: true } }, fields: {} };
74+
75+
/** A metadata door double: published rows and draft rows, each owned by a package. */
76+
function makeClient(seed: { published?: Record<string, Row>; drafts?: Record<string, Row> } = {}) {
77+
const published: Record<string, Row> = { ...(seed.published ?? {}) };
78+
const drafts: Record<string, Row> = { ...(seed.drafts ?? {}) };
79+
const save = vi.fn(
80+
async (_type: string, name: string, body: Record<string, unknown>, opts: { packageId?: string; ifNoneMatch?: string }) => {
81+
if (opts?.ifNoneMatch === '*' && drafts[name]) {
82+
throw Object.assign(new Error(`permission/${name}: this save asserted that no draft row exists`), {
83+
status: 409,
84+
code: 'METADATA_CONFLICT',
85+
});
86+
}
87+
drafts[name] = { pkg: opts?.packageId ?? '', body };
88+
return body;
89+
},
90+
);
91+
const reset = vi.fn(async (_type: string, name: string, _opts: { state?: string; packageId?: string }) => {
92+
delete drafts[name];
93+
return {};
94+
});
95+
return {
96+
published,
97+
drafts,
98+
save,
99+
reset,
100+
list: vi.fn(async (type: string, opts: { packageId?: string } = {}) => {
101+
if (type === 'object') {
102+
return [
103+
{ name: 'crm_account', label: 'Account' },
104+
{ name: 'crm_lead', label: 'Lead' },
105+
];
106+
}
107+
if (type !== 'permission') return [];
108+
return Object.values(published)
109+
.filter((r) => !opts.packageId || r.pkg === opts.packageId)
110+
.map((r) => ({ name: r.body.name, label: r.body.label }));
111+
}),
112+
listDrafts: vi.fn(async (opts: { packageId?: string; type?: string } = {}) =>
113+
Object.values(drafts)
114+
.filter((r) => !opts.packageId || r.pkg === opts.packageId)
115+
.map((r) => ({ type: 'permission', name: r.body.name, label: r.body.label, packageId: r.pkg })),
116+
),
117+
layered: vi.fn(async (_type: string, name: string) => ({
118+
effective: published[name]?.body ?? null,
119+
code: null,
120+
overlay: null,
121+
overlayScope: null,
122+
})),
123+
getDraft: vi.fn(async (type: string, name: string) =>
124+
drafts[name] ? { type, name, item: drafts[name].body } : null,
125+
),
126+
get: vi.fn(async () => ({ fields: [] })),
127+
};
128+
}
129+
130+
beforeEach(() => {
131+
window.confirm = vi.fn(() => true) as unknown as typeof window.confirm;
132+
});
133+
134+
afterEach(() => {
135+
cleanup();
136+
vi.clearAllMocks();
137+
});
138+
139+
function renderAccess(readOnly = false) {
140+
render(
141+
<MemoryRouter>
142+
<AccessPillar packageId={PKG} readOnly={readOnly} />
143+
</MemoryRouter>,
144+
);
145+
}
146+
147+
const header = () => within(screen.getByTestId('access-item-header'));
148+
const rail = () => within(screen.getByTestId('studio-rail-access'));
149+
150+
async function openCreate(label: string, name?: string) {
151+
fireEvent.click(await screen.findByTestId('studio-rail-add', undefined, { timeout: 8000 }));
152+
const dialog = await screen.findByRole('dialog', { name: en('engine.studio.access.new') });
153+
fireEvent.change(within(dialog).getByLabelText(en('engine.studio.access.nameLabel')), { target: { value: label } });
154+
if (name) {
155+
fireEvent.change(within(dialog).getByLabelText(en('engine.studio.access.idLabel')), { target: { value: name } });
156+
}
157+
fireEvent.click(within(dialog).getByRole('button', { name: en('engine.studio.createDraft') }));
158+
return dialog;
159+
}
160+
161+
describe('objectstack#26836 — "+ New" over a taken identifier', () => {
162+
it('a published set of this package: refused, nothing written, the set named, and "Open" opens it', async () => {
163+
clientImpl = makeClient({ published: { project_editor: { pkg: PKG, body: EDITOR } } });
164+
renderAccess();
165+
await screen.findByText('1 object granted · 0 field overrides', undefined, { timeout: 8000 });
166+
// Leave the set, so "Open" has somewhere to go back from.
167+
fireEvent.click(rail().getByText(en('engine.studio.owd.railLabel')));
168+
169+
const dialog = await openCreate('Project Editor');
170+
const taken = await within(dialog).findByTestId('access-create-taken');
171+
expect(taken).toHaveTextContent(
172+
tFormat('engine.studio.access.createTaken', 'en', { name: 'project_editor', label: 'Project Editor' }),
173+
);
174+
expect(clientImpl.save).not.toHaveBeenCalled();
175+
expect(clientImpl.drafts).toEqual({});
176+
177+
fireEvent.click(within(taken).getByTestId('access-create-open-existing'));
178+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
179+
expect(await screen.findByText('1 object granted · 0 field overrides')).toBeInTheDocument();
180+
expect(header().getByRole('heading', { level: 1 })).toHaveTextContent('Project Editor');
181+
expect(clientImpl.save).not.toHaveBeenCalled();
182+
});
183+
184+
it('a published set another package holds: refused, nothing written, and no "Open"', async () => {
185+
clientImpl = makeClient({ published: { project_editor: { pkg: OTHER_PKG, body: EDITOR } } });
186+
renderAccess();
187+
const dialog = await openCreate('Project Editor');
188+
const taken = await within(dialog).findByTestId('access-create-taken');
189+
expect(taken).toHaveTextContent(
190+
tFormat('engine.studio.access.createTakenElsewhere', 'en', { name: 'project_editor', label: 'Project Editor' }),
191+
);
192+
expect(within(taken).queryByTestId('access-create-open-existing')).toBeNull();
193+
expect(clientImpl.save).not.toHaveBeenCalled();
194+
expect(clientImpl.drafts).toEqual({});
195+
});
196+
197+
it('a draft set: refused before the save', async () => {
198+
clientImpl = makeClient({
199+
drafts: { project_editor: { pkg: PKG, body: { name: 'project_editor', label: 'Project Editor', objects: {} } } },
200+
});
201+
renderAccess();
202+
const dialog = await openCreate('Project Editor');
203+
expect(await within(dialog).findByTestId('access-create-taken')).toHaveTextContent(
204+
tFormat('engine.studio.access.createTaken', 'en', { name: 'project_editor', label: 'Project Editor' }),
205+
);
206+
expect(clientImpl.save).not.toHaveBeenCalled();
207+
});
208+
209+
it('a draft that lands after the check: the door\'s first-write pin still refuses it (objectui#11773)', async () => {
210+
clientImpl = makeClient();
211+
renderAccess();
212+
await screen.findByTestId('access-empty', undefined, { timeout: 8000 });
213+
// The draft appears between the pillar's read and its save.
214+
clientImpl.drafts.project_editor = {
215+
pkg: PKG,
216+
body: { name: 'project_editor', label: 'Somebody else', objects: { crm_lead: { allowRead: true } } },
217+
};
218+
clientImpl.listDrafts.mockResolvedValueOnce([]);
219+
const dialog = await openCreate('Project Editor');
220+
await waitFor(() => expect(clientImpl.save).toHaveBeenCalledTimes(1));
221+
expect(clientImpl.save.mock.calls[0][3]).toMatchObject({ mode: 'draft', packageId: PKG, ifNoneMatch: '*' });
222+
expect(await within(dialog).findByText(/asserted that no draft row exists/)).toBeInTheDocument();
223+
expect(clientImpl.drafts.project_editor.body.label).toBe('Somebody else');
224+
});
225+
226+
it('a fresh identifier: created as a draft, as before, and opened', async () => {
227+
clientImpl = makeClient({ published: { project_editor: { pkg: PKG, body: EDITOR } } });
228+
renderAccess();
229+
await screen.findByText('1 object granted · 0 field overrides', undefined, { timeout: 8000 });
230+
await openCreate('Project Viewer');
231+
await waitFor(() => expect(clientImpl.save).toHaveBeenCalledTimes(1));
232+
const [type, name, , opts] = clientImpl.save.mock.calls[0];
233+
expect([type, name]).toEqual(['permission', 'project_viewer']);
234+
expect(opts).toMatchObject({ mode: 'draft', packageId: PKG, ifNoneMatch: '*', surface: 'studio' });
235+
expect(toast.success).toHaveBeenCalledWith(tFormat('engine.studio.access.created', 'en', { label: 'Project Viewer' }));
236+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
237+
expect(await rail().findByText('project_viewer')).toBeInTheDocument();
238+
await waitFor(() => expect(header().getByText('project_viewer')).toBeInTheDocument());
239+
});
240+
});
241+
242+
describe('objectstack#26836 — discarding a set\'s pending draft', () => {
243+
it('a published set: the draft goes, the matrix reads the live set again, and the set stays', async () => {
244+
clientImpl = makeClient({
245+
published: { project_editor: { pkg: PKG, body: EDITOR } },
246+
drafts: { project_editor: { pkg: PKG, body: { name: 'project_editor', label: 'Project Editor', objects: {} } } },
247+
});
248+
renderAccess();
249+
// The blank draft is what the matrix shows.
250+
expect(await screen.findByText('0 objects granted · 0 field overrides', undefined, { timeout: 8000 })).toBeInTheDocument();
251+
252+
fireEvent.click(await header().findByTestId('access-discard-draft'));
253+
expect(window.confirm).toHaveBeenCalledWith(
254+
tFormat('engine.studio.access.discardConfirm', 'en', { item: 'Project Editor' }),
255+
);
256+
await waitFor(() => expect(clientImpl.reset).toHaveBeenCalledTimes(1));
257+
expect(clientImpl.reset.mock.calls[0].slice(0, 3)).toEqual(['permission', 'project_editor', { state: 'draft', packageId: PKG }]);
258+
expect(clientImpl.drafts).toEqual({});
259+
expect(toast.success).toHaveBeenCalledWith(tFormat('engine.edit.draftDiscarded', 'en', { item: 'Project Editor' }));
260+
261+
expect(await screen.findByText('1 object granted · 0 field overrides')).toBeInTheDocument();
262+
expect(rail().getByText('project_editor')).toBeInTheDocument();
263+
await waitFor(() => expect(screen.queryByTestId('access-discard-draft')).toBeNull());
264+
expect(clientImpl.save).not.toHaveBeenCalled();
265+
});
266+
267+
it('a set never published: discarding its draft takes it off the rail', async () => {
268+
clientImpl = makeClient({
269+
published: { project_editor: { pkg: PKG, body: EDITOR } },
270+
drafts: { project_viewer: { pkg: PKG, body: { name: 'project_viewer', label: 'Project Viewer', objects: {} } } },
271+
});
272+
renderAccess();
273+
fireEvent.click(await rail().findByText('project_viewer', undefined, { timeout: 8000 }));
274+
fireEvent.click(await header().findByTestId('access-discard-draft'));
275+
expect(window.confirm).toHaveBeenCalledWith(
276+
tFormat('engine.studio.access.discardConfirmUnpublished', 'en', { item: 'Project Viewer' }),
277+
);
278+
await waitFor(() => expect(rail().queryByText('project_viewer')).toBeNull());
279+
expect(clientImpl.reset.mock.calls[0].slice(0, 3)).toEqual(['permission', 'project_viewer', { state: 'draft', packageId: PKG }]);
280+
// The rail's remaining set opens in its place.
281+
expect(await screen.findByText('1 object granted · 0 field overrides')).toBeInTheDocument();
282+
});
283+
284+
it('a declined confirm discards nothing', async () => {
285+
clientImpl = makeClient({
286+
published: { project_editor: { pkg: PKG, body: EDITOR } },
287+
drafts: { project_editor: { pkg: PKG, body: { name: 'project_editor', label: 'Project Editor', objects: {} } } },
288+
});
289+
window.confirm = vi.fn(() => false) as unknown as typeof window.confirm;
290+
renderAccess();
291+
fireEvent.click(await screen.findByTestId('access-discard-draft', undefined, { timeout: 8000 }));
292+
expect(window.confirm).toHaveBeenCalledTimes(1);
293+
expect(clientImpl.reset).not.toHaveBeenCalled();
294+
expect(screen.getByTestId('access-discard-draft')).toBeInTheDocument();
295+
});
296+
297+
it('no discard without a pending draft, nor on a read-only package', async () => {
298+
clientImpl = makeClient({ published: { project_editor: { pkg: PKG, body: EDITOR } } });
299+
renderAccess();
300+
await screen.findByText('1 object granted · 0 field overrides', undefined, { timeout: 8000 });
301+
expect(screen.queryByTestId('access-discard-draft')).toBeNull();
302+
cleanup();
303+
304+
clientImpl = makeClient({
305+
published: { project_editor: { pkg: PKG, body: EDITOR } },
306+
drafts: { project_editor: { pkg: PKG, body: { name: 'project_editor', label: 'Project Editor', objects: {} } } },
307+
});
308+
renderAccess(true);
309+
await screen.findByText('0 objects granted · 0 field overrides', undefined, { timeout: 8000 });
310+
expect(screen.queryByTestId('access-discard-draft')).toBeNull();
311+
});
312+
});

0 commit comments

Comments
 (0)