Skip to content

Commit ff648d7

Browse files
fix(app-shell): Studio Access pillar polish — plain-words draft race refusal, OWD banner clears on revert, live rail label, field table count and pinned name column (#27148)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8SS6ia75ydEcRpPoKXe7Wi8)_ Fixes #26846 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:Studio 访问(Access)支柱四处小修:草稿抢先创建时的拒绝改成作者能懂的话;OWD 总览撤回改动后拒绝提示随之消失;权限集改名后左侧列表立即显示新标签;字段子表的「可见/总数」计数挪到筛选框旁、字段名列横向滚动时固定在左侧。第五项(恢复已忽略的建议绑定)服务端没有对应接口,本 PR 不做,交席位立服务端卡。 - 为什么改:QA 在一次 Studio 巡检里实测到这五处,单个都小,合起来让访问支柱显得不可靠(卡 #26846,分诊 p3)。 - 风险与回滚:只动 `@object-ui/app-shell` 三个组件与 `@object-ui/i18n` 新增一个键,无契约、无服务端改动;回滚即 revert 本 PR。 - 先看:`StudioDesignSurface.tsx`(AccessPillar 的创建拒绝映射与矩阵保存后重读列表)、`PackageOwdOverviewPanel.tsx`(回到无改动时清除拒绝)、`PermissionMatrixEditor.tsx`(字段子表的计数位置与固定列)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/PackageOwdOverviewPanel.refusalClearsOnRevert-26846.test.tsx packages/app-shell/src/views/studio-design/StudioDesignSurface.accessRailLabelLive-26846.test.tsx packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.fieldTableLayout-26846.test.tsx packages/app-shell/src/views/studio-design/StudioDesignSurface.accessCreateTaken-26836.test.tsx`,预期 4 个文件、15 个测试全绿。 - 需要你做的:无,席位落地。 ## What changes Each item was re-reproduced on `origin/main` `07d18e69` first (the QA run was at `3fcafcd3`). 1. **Duplicate-draft refusal in plain words (item 1, narrowed).** The card's path no longer reproduces: since #26836 the Access "+ New" reads `findTakenIdentifier` before its save and refuses a taken identifier with an author-facing notice, writing nothing. The server's first-write-pin sentence still reached the dialog in the window between that check and the save, and the existing race test pinned it verbatim. `AccessPillar.doCreate` now reads that refusal by its code: this create always sends the pin (`ifNoneMatch: '*'`), so `METADATA_CONFLICT` means the identifier holds a draft, and the dialog shows `engine.studio.access.createDraftTaken` (en + zh, new in `@object-ui/i18n`). Any other refusal keeps its own text. The server message is unchanged. 2. **OWD refusal banner outlives its revert (item 2).** The refusal state lives in `PackageOwdOverviewPanel` (the Access pillar's sharing-baseline overview), not in `PermissionMatrixEditor`. Its `saveError` now clears on the dirty-to-clean transition, when every row is back at its loaded value ("No changes"). A row that is still dirty keeps the banner. 3. **Stale set label in the Access rail (item 3).** The matrix page's `onDraftSaved` in `AccessPillar` updated only the draft-name set; the rail list was read on mount, publish and the live-metadata pulse. That callback now also calls the pillar's own `load()`, so a label an autosave changed shows at once, with no polling. 4. **Clipped field counter, non-sticky name column (item 4).** The studio matrix has a `min-w-[1304px]` floor, wider than the Studio content column beside the rail at 1440 px, so the field sub-table's `ml-auto` counter sat past the visible edge of the scroll container. The counter now sits right after the field filter (`shrink-0 whitespace-nowrap`), and the field-name header and cells carry `FIELD_NAME_CELL` (`sticky left-0 z-[1] bg-background`), so the column stays in view while the matrix scrolls sideways. The sub-table is shared, so the non-Studio matrix page gets the same. 5. **No re-grant for a dismissed suggestion (item 5): left out.** Measured: the server exposes only `GET /security/suggested-bindings`, `POST …/:id/confirm` and `POST …/:id/dismiss`; `confirmAudienceBindingSuggestion` in plugin-security refuses any row whose status is not `pending` ("only pending suggestions can be accepted"), and no route re-opens a dismissed row. Per the dispatch order no endpoint is invented here; the seat can file a server-side card. ## Why Five small Access-pillar defects measured in one Studio QA pass (#26846). Triage graded them p3 together: "One PR is fine." ## Tests All at head `8220f32d` (after merging `origin/main`, which brought #26999 into the same two files). - New `PackageOwdOverviewPanel.refusalClearsOnRevert-26846.test.tsx`: a revert to the loaded value clears the banner; CONTROL: a still-dirty edit keeps it. - New `StudioDesignSurface.accessRailLabelLive-26846.test.tsx`: the real `AccessPillar` with a matrix stand-in that reports one draft save; the rail row goes from "Dispatcher" to "Dispatcher v2". - New `PermissionMatrixEditor.fieldTableLayout-26846.test.tsx`: the count is the filter's next sibling and has no `ml-auto`; the name header and every name cell carry the pinned classes; CONTROL: a box cell is not sticky. jsdom lays nothing out, so this pins structure and classes, not pixels. - Updated `StudioDesignSurface.accessCreateTaken-26836.test.tsx`: the race test now expects the author-facing sentence and the absence of the pin sentence; a new CONTROL keeps a `VALIDATION_ERROR` refusal's own text. <details><summary>Runs</summary> - The four files: `Test Files 4 passed (4)`, `Tests 15 passed (15)` (lock VERDICT command-exit 0). - Every app-shell test file naming `PermissionMatrixEdit`, `AccessPillar`, `PackageOwdOverviewPanel` or an `engine.studio.access.` key (65 files): `Test Files 65 passed (65)`, `Tests 595 passed (595)` (lock VERDICT command-exit 0, 5m37s). - `pnpm --filter @object-ui/app-shell run type-check` exit 0; `pnpm --filter @object-ui/i18n run type-check` exit 0; `pnpm --dir ui run type-check:scripts` exit 0. - Ablations, one per behaviour, each through `scripts/ablation-replace.mjs` with the restore proved by blob equal to HEAD and an empty `git diff HEAD`: - item 1, the `METADATA_CONFLICT` test replaced by `false`: the race test fails, 9 others pass. - item 2, the clear replaced by `void 0;`: the revert test fails, its CONTROL passes. - item 3, the `void load();` deleted: the rail-label test fails. (A first attempt that replaced the line was refused by the tool before any run, the replacement count not rising; the delete form ran.) - item 4, `FIELD_NAME_CELL` emptied: the pinned-column test fails, the counter test passes. - item 4, the counter's class put back to `ml-auto …`: the counter test fails, the pinned-column test passes. </details> ## Acceptance notes - Item 1: the card's reproduction no longer reproduces on main (#26836 added the pre-save check); only the check-to-save race reached the author, and that is what is mapped. Other Studio creates (flow, object, app, interfaces) share the same race shape and still show the door's sentence there; noted, not changed here. - Item 3: the rail shows a PUBLISHED set by its published label and a draft-only set by its `_drafts` row label (ruling 6053526025, question 1). A label edit on a published set's pending draft therefore still shows the published label until publish; this PR does not change that reading. - Item 4 was reasoned from the matrix's `min-w-[1304px]` floor against the Studio column width, not measured in a browser at 1440 x 900. ## Verification - Derived gates (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `8220f32d`): 34 commands, all exit 0; `--ran` reconciliation: "34 derived famil(ies) accounted for — 34 run, 0 NOT-MEASURED". Also run outside the derivation: `pnpm check:changeset-fixed` exit 0 and `pnpm --dir ui run check:new-line-citations` exit 0 ("0 new citation(s)"). - `dispatch-gates --tier`: no path-derived mandate; 349 changed lines (+341 / -8), under the 3000 threshold. - Changeset `.changeset/26846-studio-access-polish.md`: `@object-ui/i18n` patch (new key), `@object-ui/app-shell` patch. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (untouched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` is CI's. - [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: n/a. - [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 (the 维护者速读 block is the dispatch order's Chinese section). --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbYZa5ViBSW4Et4fr91)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent a315560 commit ff648d7

10 files changed

Lines changed: 341 additions & 8 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/i18n': patch
3+
'@object-ui/app-shell': patch
4+
---
5+
6+
fix(app-shell): four small fixes to the Studio Access pillar
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **A create that loses the race to a draft says so in plain words.** When a draft permission set with the same identifier appears between Studio's check and its save, the server refuses the save. Studio used to show the server's sentence about the "first-write pin" and `If-None-Match`. It now reads the refusal by its code and says that a draft with that identifier already exists. New key `engine.studio.access.createDraftTaken` in `@object-ui/i18n` (en, zh).
12+
- **The sharing-baseline (OWD) overview drops a refusal once its edits are undone.** After a refused save, putting every row back to its loaded value ("No changes") now removes the refusal. An edit that is still unsaved keeps it.
13+
- **The Access list follows a label change at once.** A draft save of the open permission set re-reads the list, so a renamed set shows its new label without a publish or a reload.
14+
- **The field table under an expanded object stays readable when the matrix scrolls sideways.** The "shown / total" count sits beside the field filter instead of at the far end of the row, and the field-name column stays pinned to the left edge.
15+
16+
What to do: nothing. Nothing a permission set or object declares changes.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26846 — the field sub-table under an expanded object stays
5+
* readable when the matrix scrolls sideways (its floor is wider than the
6+
* Studio content column at 1440 px):
7+
*
8+
* - the "visible / total" count sits beside the filter it counts, not at the
9+
* far end of the row, where it fell past the visible edge;
10+
* - the field-name column, header and rows, is pinned to the left edge of the
11+
* scroll container over an opaque background.
12+
*
13+
* jsdom lays nothing out, so this pins the structure and the classes the
14+
* browser reads; it is no screenshot.
15+
*/
16+
17+
import '@testing-library/jest-dom/vitest';
18+
import { afterEach, describe, expect, it, vi } from 'vitest';
19+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
20+
import { MemoryRouter } from 'react-router-dom';
21+
import { t } from './i18n';
22+
23+
// `withPreviewDrafts` answers the client itself, not a promise (objectstack#26840).
24+
type FakeClient = Record<string, (...args: never[]) => unknown>;
25+
26+
let clientImpl: FakeClient;
27+
28+
vi.mock('./useMetadata', () => ({
29+
useMetadataClient: () => clientImpl,
30+
useMetadataTypes: () => ({
31+
loading: false,
32+
error: null,
33+
entries: [{ type: 'permission', label: 'Permission', allowEnvironmentOverlay: true }],
34+
}),
35+
}));
36+
37+
vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));
38+
39+
import { PermissionMatrixEditPage } from './PermissionMatrixEditor';
40+
41+
afterEach(cleanup);
42+
43+
// Eight fields, so the filter shows (threshold is > 6).
44+
const FIELDS = ['amount', 'email', 'name', 'notes', 'phone', 'region', 'revenue', 'status'];
45+
46+
function makeClient(): FakeClient {
47+
return {
48+
layered: async () => ({
49+
effective: { name: 'project_editor', label: 'Project editor', objects: { a_project: { allowRead: true } }, fields: {} },
50+
code: null,
51+
overlay: null,
52+
overlayScope: null,
53+
}),
54+
getDraft: async () => null,
55+
withPreviewDrafts() { return this; },
56+
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_project' } }] : []),
57+
get: async (type: string) => (type === 'object' ? { fields: FIELDS.map((name) => ({ name })) } : null),
58+
save: async (_type: string, _name: string, payload: Record<string, unknown>) => payload,
59+
};
60+
}
61+
62+
async function renderExpanded() {
63+
clientImpl = makeClient();
64+
render(
65+
<MemoryRouter>
66+
<PermissionMatrixEditPage type="permission" name="project_editor" packageId="app.a" />
67+
</MemoryRouter>,
68+
);
69+
fireEvent.click(await screen.findByRole('button', { name: /a_project/ }));
70+
await screen.findByLabelText('a_project.notes readable');
71+
}
72+
73+
const PINNED = ['sticky', 'left-0', 'bg-background'];
74+
75+
describe('the field sub-table when the matrix scrolls sideways (objectstack#26846)', () => {
76+
it('the count sits right after the filter, not pushed to the far end of the row', async () => {
77+
await renderExpanded();
78+
const count = screen.getByTestId('perm-field-count-a_project');
79+
expect(count).toHaveTextContent('8 / 8');
80+
expect(count.previousElementSibling).toBe(screen.getByLabelText(t('perm.field.filter', 'en')));
81+
expect(count).not.toHaveClass('ml-auto');
82+
expect(count).toHaveClass('shrink-0', 'whitespace-nowrap');
83+
});
84+
85+
it('the field-name column is pinned left, header and every row', async () => {
86+
await renderExpanded();
87+
const header = screen.getByRole('columnheader', { name: t('perm.field.col.name', 'en') });
88+
expect(header).toHaveClass(...PINNED);
89+
for (const f of FIELDS) {
90+
expect(screen.getByTestId(`perm-field-name-a_project-${f}`)).toHaveClass(...PINNED);
91+
}
92+
// CONTROL: the boxes beside it scroll; only the name column is pinned.
93+
expect(screen.getByLabelText('a_project.notes readable').closest('td')).not.toHaveClass('sticky');
94+
});
95+
});

‎ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3007,6 +3007,13 @@ function StudioObjectRow({
30073007
);
30083008
}
30093009

3010+
/**
3011+
* objectstack#26846 — the field sub-table's name cell, header and rows alike:
3012+
* pinned to the left edge of the matrix's scroll container, over an opaque
3013+
* background so the boxes scrolling under it do not show through.
3014+
*/
3015+
const FIELD_NAME_CELL = 'sticky left-0 z-[1] bg-background pr-2';
3016+
30103017
/**
30113018
* The inherited look, shared by object and field boxes: grey with a dashed
30123019
* edge, so a box that draws a grant the set inherits never reads as one the
@@ -3164,6 +3171,10 @@ function FieldsSubTable({
31643171
<div className="space-y-2">
31653172
{/* Field toolbar (B4): filter + bulk over the visible field set. The
31663173
filter only appears once one-by-one would actually hurt. */}
3174+
{/* objectstack#26846 — the count sits beside the filter it counts. At
3175+
the far end of the row it sat past the visible edge whenever the
3176+
matrix scrolled sideways (its floor is wider than the Studio
3177+
content column at 1440 px). */}
31673178
<div className="flex flex-wrap items-center gap-2">
31683179
{fields.length > 6 && (
31693180
<Input
@@ -3174,6 +3185,12 @@ function FieldsSubTable({
31743185
className="h-7 w-44 text-xs"
31753186
/>
31763187
)}
3188+
<span
3189+
data-testid={`perm-field-count-${objectName}`}
3190+
className="shrink-0 whitespace-nowrap text-[11px] tabular-nums text-muted-foreground"
3191+
>
3192+
{visible.length} / {fields.length}
3193+
</span>
31773194
{writable && (
31783195
<div className="flex items-center gap-1">
31793196
<Button
@@ -3205,9 +3222,6 @@ function FieldsSubTable({
32053222
</Button>
32063223
</div>
32073224
)}
3208-
<span className="ml-auto text-[11px] text-muted-foreground">
3209-
{visible.length} / {fields.length}
3210-
</span>
32113225
</div>
32123226
{/* objectstack#26597 — what a grey box means, said once per sub-table. */}
32133227
{visible.some((f) => fieldsState[`${objectName}.${f.name}`] === undefined) && (
@@ -3221,7 +3235,9 @@ function FieldsSubTable({
32213235
<table className="w-full text-xs">
32223236
<thead>
32233237
<tr className="text-muted-foreground">
3224-
<th className="text-left py-1 font-medium">{t('perm.field.col.name')}</th>
3238+
{/* objectstack#26846 — the name column stays in view while the
3239+
matrix scrolls sideways (FIELD_NAME_CELL). */}
3240+
<th className={cn('text-left py-1 font-medium', FIELD_NAME_CELL)}>{t('perm.field.col.name')}</th>
32253241
<th className="px-2 py-1 font-medium w-16 text-center" title={t('perm.field.read')}>{t('perm.field.read')}</th>
32263242
<th className="px-2 py-1 font-medium w-16 text-center" title={t('perm.field.edit')}>{t('perm.field.edit')}</th>
32273243
<th className="px-2 py-1 font-medium text-left" title={t('perm.field.when.tip')}>{t('perm.field.when')}</th>
@@ -3237,7 +3253,7 @@ function FieldsSubTable({
32373253
return (
32383254
<React.Fragment key={f.name}>
32393255
<tr className="border-t border-muted">
3240-
<td className="py-1">
3256+
<td className={cn('py-1', FIELD_NAME_CELL)} data-testid={`perm-field-name-${objectName}-${f.name}`}>
32413257
{f.label ?? f.name}
32423258
{f.label && (
32433259
<span className="ml-1 text-muted-foreground">({f.name})</span>
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26846 — a refused OWD save's banner answers the edits it was
5+
* sent. Reverting every row to its loaded value ("No changes") takes it away;
6+
* CONTROL: an edit that is still dirty keeps it.
7+
*/
8+
9+
import '@testing-library/jest-dom/vitest';
10+
import * as React from 'react';
11+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
12+
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
13+
import { t } from '../metadata-admin/i18n';
14+
import { PackageOwdOverviewPanel } from './PackageOwdOverviewPanel';
15+
16+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
17+
18+
const REFUSAL = 'object/crm_memo: a controlled_by_parent object needs exactly one required master_detail field';
19+
const CBP = t('engine.studio.settings.sharingControlledByParent', 'en-US');
20+
const PRIVATE = t('engine.studio.settings.sharingPrivate', 'en-US');
21+
const PUBLIC_READ = t('engine.studio.settings.sharingPublicRead', 'en-US');
22+
23+
function makeClient() {
24+
const body = { name: 'crm_memo', label: 'Memo', sharingModel: 'private', fields: {} };
25+
return {
26+
list: async (type: string) => (type === 'object' ? [{ name: 'crm_memo', label: 'Memo' }] : []),
27+
listDrafts: async () => [],
28+
layered: async () => ({ effective: body, code: null }),
29+
getDraft: async () => null,
30+
save: vi.fn(async () => {
31+
throw Object.assign(new Error(REFUSAL), { status: 422, code: 'VALIDATION_ERROR' });
32+
}),
33+
} as unknown as React.ComponentProps<typeof PackageOwdOverviewPanel>['client'];
34+
}
35+
36+
async function pick(label: string) {
37+
fireEvent.keyDown(screen.getByTestId('owd-internal-crm_memo'), { key: 'ArrowDown' });
38+
fireEvent.click(within(await screen.findByRole('listbox')).getByRole('option', { name: label }));
39+
}
40+
41+
async function refuseOnce() {
42+
render(<PackageOwdOverviewPanel client={makeClient()} packageId="com.example.crm" locale="en-US" />);
43+
await screen.findByTestId('owd-row-crm_memo');
44+
await pick(CBP);
45+
fireEvent.click(screen.getByTestId('owd-save'));
46+
await screen.findByText(new RegExp(REFUSAL));
47+
}
48+
49+
beforeEach(() => {
50+
Element.prototype.scrollIntoView = vi.fn();
51+
});
52+
afterEach(cleanup);
53+
54+
describe('PackageOwdOverviewPanel — the refusal banner and the revert (objectstack#26846)', () => {
55+
it('reverting the refused edit to the loaded value clears the banner', async () => {
56+
await refuseOnce();
57+
await pick(PRIVATE);
58+
expect(screen.getByTestId('owd-save')).toHaveTextContent(t('engine.studio.owd.saveNone', 'en-US'));
59+
await waitFor(() => expect(screen.queryByText(new RegExp(REFUSAL))).toBeNull());
60+
});
61+
62+
it('CONTROL: an edit that is still dirty keeps the banner', async () => {
63+
await refuseOnce();
64+
await pick(PUBLIC_READ);
65+
expect(screen.getByTestId('owd-dirty-crm_memo')).toBeInTheDocument();
66+
expect(screen.getByText(new RegExp(REFUSAL))).toBeInTheDocument();
67+
});
68+
});

‎ui/packages/app-shell/src/views/studio-design/PackageOwdOverviewPanel.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,12 @@ export function PackageOwdOverviewPanel({
334334
},
335335
[],
336336
);
337+
// objectstack#26846 — a refusal answers the edits it was sent. Once every
338+
// row is back at its loaded value ("No changes") there is nothing left for
339+
// it to judge, so it goes; an edit that stays dirty keeps it.
340+
React.useEffect(() => {
341+
if (!isDirty) setSaveError(null);
342+
}, [isDirty]);
337343

338344
const doSave = React.useCallback(async () => {
339345
const changed = rowState.filter((s) => s.dirty);

‎ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.accessCreateTaken-26836.test.tsx‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
* - Create over a published id another package holds: refused, nothing
1616
* written, and no "Open".
1717
* - 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.
18+
* carries the first-write pin for a draft that lands after the check;
19+
* that pin's refusal reads in the author's words (objectstack#26846).
1920
* - A fresh id: created as before.
2021
* - Discard: a published set's draft goes and the matrix reads the live
2122
* set again; a never-published set leaves the rail.
@@ -221,10 +222,29 @@ describe('objectstack#26836 — "+ New" over a taken identifier', () => {
221222
const dialog = await openCreate('Project Editor');
222223
await waitFor(() => expect(clientImpl.save).toHaveBeenCalledTimes(1));
223224
expect(clientImpl.save.mock.calls[0][3]).toMatchObject({ mode: 'draft', packageId: PKG, ifNoneMatch: '*' });
224-
expect(await within(dialog).findByText(/asserted that no draft row exists/)).toBeInTheDocument();
225+
// objectstack#26846 — read by its code, in the author's words: the door's
226+
// sentence about the pin never reaches the dialog.
227+
expect(
228+
await within(dialog).findByText(tFormat('engine.studio.access.createDraftTaken', 'en', { name: 'project_editor' })),
229+
).toBeInTheDocument();
230+
expect(within(dialog).queryByText(/asserted that no draft row exists/)).toBeNull();
225231
expect(clientImpl.drafts.project_editor.body.label).toBe('Somebody else');
226232
});
227233

234+
it('CONTROL (objectstack#26846): a refusal with another code keeps its own text', async () => {
235+
clientImpl = makeClient();
236+
renderAccess();
237+
await screen.findByTestId('access-empty', undefined, { timeout: 8000 });
238+
clientImpl.save.mockRejectedValueOnce(
239+
Object.assign(new Error('permission/project_editor: label is too long'), { status: 422, code: 'VALIDATION_ERROR' }),
240+
);
241+
const dialog = await openCreate('Project Editor');
242+
expect(await within(dialog).findByText(/label is too long/)).toBeInTheDocument();
243+
expect(
244+
within(dialog).queryByText(tFormat('engine.studio.access.createDraftTaken', 'en', { name: 'project_editor' })),
245+
).toBeNull();
246+
});
247+
228248
it('a fresh identifier: created as a draft, as before, and opened', async () => {
229249
clientImpl = makeClient({ published: { project_editor: { pkg: PKG, body: EDITOR } } });
230250
renderAccess();

0 commit comments

Comments
 (0)