Skip to content

Commit d7df639

Browse files
fix(app-shell): Studio's Automations, App and Data creates refuse an identifier a published or draft item already holds (#26987)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbQ45wNg97BSRszX9MTov2am)_ Fixes #26917 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:Studio 的「新建自动化」「创建应用」「新建对象」在写入前,先查同类型已发布和草稿中的同名项(跨所有软件包);标识已被占用就拒绝、不写入,提示里点名占用者;占用者在本支柱左栏里(本软件包的流程或对象)时给出「打开它」。三处与「访问」页共用一个检查函数。 - **为什么改**:首写保护(`If-None-Match: *` + 草稿模式)只拦已有草稿,不拦已发布的同名项。实测(见下)三种类型都会在已发布项上叠一份空白草稿,发布软件包就会用空白项替换线上的流程、应用或对象。 - **风险与回滚**:低。只改 `StudioDesignSurface.tsx` 的三个新建回调与对话框,沿用现有接口(列表、草稿列表);服务端、spec 不动。回滚即还原本 PR。 - **先看**:`findTakenIdentifier` 与 `CreateTakenNotice`(文件上部),`doCreateFlow` / `doCreateApp` / `doCreateObject` 开头的占用检查;`StudioDesignSurface.createTaken-26917.test.tsx`。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/StudioDesignSurface.createTaken-26917.test.tsx`,预期 8 个测试全绿。 - **需要你做的**:无,席位落地。 ## What changes - **One shared check.** `findTakenIdentifier(client, type, name, locale)` reads the published items and the draft headers of a type across every package (`client.list(type)`, `client.listDrafts({ type })`, the unscoped pair the Interfaces and Access creates read) and names the item holding the identifier, or `null`. `CreateTakenNotice` renders the refusal in the create dialog: a sentence naming the holder and, when the holder is on the pillar's own rail, an "Open “LABEL”" button. The Access create (#26916) now uses both instead of its inline copy; its behaviour, sentences and test ids are unchanged. - **New automation** (`doCreateFlow`): an identifier a flow holds is refused before anything is written. A flow on this package's rail gets "Open", which closes the dialog and opens it (the New button already asked about a held step, #26927, so Open asks nothing more); a flow another package holds gets no open path. - **New object** (`doCreateObject`): the rail check it had (same package only) becomes the "here" arm of the same refusal, with "Open"; an object another package holds (the namespace-prefixed identifier) is now refused too. - **Create app** (`doCreateApp`): an identifier an app holds is refused. Create app is offered only to a package with no app, so the holder is always another package's and there is no "Open". - A fresh identifier is created exactly as before: the draft save with `mode: 'draft'`, `packageId`, `ifNoneMatch: '*'`, `surface: 'studio'`. The pin still refuses a draft that lands between the check and the save. - **Strings** (`@object-ui/i18n`, `studio` namespace, `en` and `zh`): five refusal rows (`engine.studio.auto.createTaken`, `…auto.createTakenElsewhere`, `…app.createTaken`, `…data.createTaken`, `…data.createTakenElsewhere`); `engine.studio.access.openExisting` becomes the shared `engine.studio.create.openExisting`; `engine.studio.data.idExists`, which the new refusal replaces, is removed. - One root changeset: `@object-ui/app-shell` patch, `@object-ui/i18n` patch. No `packages/spec/**`, no server change, no metadata key. ## Why The card's order was to measure each create first. Measured at the metadata door (`ObjectStackProtocolImplementation.saveMetaItem`, the in-memory engine double of `protocol.served-content-hash.test.ts`, both packages installed in the registry): publish an item, then save a draft of the same name with `parentVersion: null` (the REST door's `If-None-Match: *`) and `mode: 'draft'`, as each create does. | type | published in | draft saved from | result | | --- | --- | --- | --- | | flow | same package | `com.acme.app` | written: active row "Live" + draft row "Blank" | | flow | other package | `com.acme.app` | written: active row (other) + draft row (acme) | | app | same / other package | `com.acme.app` | written over it, both cases | | object | same / other package | `com.acme.app` | written over it, both cases | | permission (control, #26836) | same / other package | `com.acme.app` | written over it, both cases | So no create was already safe at the door. The Data create was safe only for an object on its own rail (same package), which it already refused; the other two checked nothing. The probe was a scratch test, not committed. ## Tests `StudioDesignSurface.createTaken-26917.test.tsx`, eight cases on the real `AutomationsPillar`, `DataPillar` and `StudioDesignSurface` (for Create app), over a client double that keeps published and draft rows per package, writes a draft over a published row the way the door does, and refuses the pin over a draft row: - flow on this package's rail: refused, `save` never called, no draft row, the published body unchanged, the sentence names it, and "Open" opens it (its draft read follows); - flow another package holds: refused, nothing written, body unchanged, no "Open"; - flow, fresh identifier: saved with the four options above; - object another package holds (`acme_ticket`): refused, nothing written, body unchanged, no "Open"; - object on this package's rail: refused, nothing written, "Open" opens it; - object, fresh identifier: saved with the four options; - app another package holds: refused, nothing written, body unchanged, no "Open"; - app, fresh identifier: saved with the four options. ## Acceptance notes - **Race window, as on Access:** an item published (or created in another package) between the create's two reads and its save is not refused by the pin; a draft that lands there still is. Closing it for every client would need the door's draft-mode pin to judge every lifecycle, a spec and server change the seat ruled out on #26836 (answer A). - **Create app's sentence names no package.** Create app is shown only when the package has no app, so "this package already has one" cannot occur; the one sentence says an app holds the identifier. - The object create's rail arm keeps its no-read fast path: an identifier on the rail is refused before the two reads. ## Verification All at head `efc675b9` (this branch merged `origin/main` `b1b153a3`, which brought #26927's held-edit question on New), in a worktree, every build and test under `scripts/pm/os-verify-lock.sh`. <details><summary>New tests, ablation, type-check, lint and nearby suites</summary> - `pnpm --dir ui exec vitest run` on the new file, the Access file (#26836) and #26927's three `held*Create-26845` files: `Test Files 5 passed (5)`, `Tests 25 passed (25)`. - Ablation (`node scripts/ablation-replace.mjs`, anchor ` if (!publishedRow && !draftRow) return null;` replaced by ` if (true || (!publishedRow && !draftRow)) return null;`, anchor x1 to x0) over the new file and the Access file: `Tests 7 failed | 10 passed (17)`, exactly the seven refusals that go through `findTakenIdentifier` (three Access, two flow, object-elsewhere, app); the object-on-rail case stays green because it is refused by the rail arm before the read. Restored: blob equal to HEAD, `git diff HEAD` empty. - `pnpm --filter @object-ui/app-shell run type-check && pnpm --filter @object-ui/i18n run type-check`: `VERDICT command-exit 0` (app-shell's covers the test file). - eslint on the two app-shell files and the two catalogues: 0 errors; `StudioDesignSurface.tsx` carries the same 14 warnings as on `origin/main`. - Narrowed suites: every app-shell test file naming `AutomationsPillar`, `DataPillar`, `AccessPillar`, `StudioDesignSurface` or an `engine.studio.(auto|data|app|access|create).` key (178 files), plus all of `packages/i18n/`: `Test Files 267 passed (267)`, `Tests 2780 passed | 13 skipped (2793)`. 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`: `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 (the same list before and after the merge of main); every one exited 0 at `efc675b9`, the PR-body gates (`check-closing-keyword-parity`, `check-adr-0087-registration --base origin/main`, `check-changeset-no-major --base origin/main`) against this body through `GITHUB_EVENT_PATH`, and `check-partof-closing-keyword` with `PR_BODY`: exit 0. - `pnpm --dir ui exec vitest run scripts/`: `Test Files 178 passed | 2 skipped (180)`, `Tests 5065 passed | 2 skipped (5067)`. - `pnpm check:type-check-debt`: `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. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (eslint on the touched files: 0 errors; the full lint runs in CI) - [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. (the 维护者速读 section is the maintainer's Chinese summary) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jUEVZh2LeEfPDe25SW --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e79be7d commit d7df639

5 files changed

Lines changed: 719 additions & 165 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
fix(app-shell): Studio's Automations, App and Data creates refuse an identifier a published or draft item of that type already holds
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **Before.** "New automation", "Create app" and "New object" saved their 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. An identifier a published flow, app or object already held therefore got the skeleton written as that item's pending draft, and publishing the package would have replaced the live item with the blank one. The Data create checked only the objects on its own rail; the other two checked nothing.
12+
- **Now.** Before anything is written, each create reads the published items and the drafts of its type across every package, the read the Interfaces and Access creates already make (now one shared helper). An identifier one of them holds is refused in the dialog with a sentence naming the item; when the item is on the pillar's own rail (a flow or an object of this package), the dialog offers to open it instead. A fresh identifier is created as before, and the door's first-write pin still refuses a draft that lands between the check and the save.
13+
- **Strings** (`@object-ui/i18n`). Five new rows in the `studio` namespace (`en` and `zh`) for the refusals; the Access refusal's "Open" row becomes `engine.studio.create.openExisting`, shared by every create, and the Data pillar's `engine.studio.data.idExists`, which the new refusal replaces, is removed.
Lines changed: 382 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26917 — Studio's Automations, App and Data creates over an
5+
* identifier a published item of that type already holds.
6+
*
7+
* Each create saves its blank skeleton under the first-write pin
8+
* (`If-None-Match: *` with `?mode=draft`), which asserts that no DRAFT row
9+
* exists: the spec's `parentVersion: null` is lifecycle-scoped. Over a
10+
* PUBLISHED flow, app or object of the same identifier the door therefore
11+
* wrote the skeleton as that item's pending draft, and publishing the package
12+
* would have replaced the live item with the blank one. The Access create was
13+
* fixed the same way by objectstack#26836.
14+
*
15+
* - Automations: a published flow on this rail is refused with "Open"; one
16+
* another package holds is refused without it; nothing is written either way.
17+
* - Data: the same for objects (the namespace-prefixed identifier).
18+
* - App: an app another package holds is refused (Create app is offered only
19+
* to a package with no app, so there is nothing of its own to open).
20+
* - A fresh identifier is created as before, with the pin.
21+
*
22+
* The REAL pillars and dialogs, over a client double that keeps published and
23+
* draft rows per package, writes a draft over a published row the way the
24+
* door does, and refuses the pin over a draft row.
25+
*/
26+
27+
import '@testing-library/jest-dom/vitest';
28+
import * as React from 'react';
29+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
30+
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
31+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
32+
33+
const PKG = 'com.acme.app';
34+
const OTHER_PKG = 'com.other.app';
35+
36+
type Row = { pkg: string; body: Record<string, unknown> };
37+
38+
const door = vi.hoisted(() => ({
39+
/** `type/name` → the published row. */
40+
published: new Map<string, { pkg: string; body: Record<string, unknown> }>(),
41+
/** `type/name` → the pending draft row. */
42+
drafts: new Map<string, { pkg: string; body: Record<string, unknown> }>(),
43+
}));
44+
45+
const mockClient = vi.hoisted(() => {
46+
const rows = (map: Map<string, { pkg: string; body: Record<string, unknown> }>, type: string | undefined, pkg?: string | null) =>
47+
[...map.entries()]
48+
.filter(([key, row]) => (!type || key.startsWith(`${type}/`)) && (!pkg || row.pkg === pkg))
49+
.map(([key, row]) => ({ key, ...row }));
50+
const client: Record<string, unknown> = {
51+
list: vi.fn(async (type: string, opts: { packageId?: string | null } = {}) =>
52+
rows(door.published, type, opts?.packageId).map((r) => ({ ...r.body, _packageId: r.pkg })),
53+
),
54+
listDrafts: vi.fn(async (opts: { packageId?: string | null; type?: string } = {}) =>
55+
rows(door.drafts, opts?.type, opts?.packageId).map((r) => ({
56+
type: r.key.split('/')[0],
57+
name: r.body.name,
58+
label: r.body.label,
59+
packageId: r.pkg,
60+
})),
61+
),
62+
listTypes: vi.fn(async () => ({ entries: [] })),
63+
get: vi.fn(async (type: string, name: string) => door.published.get(`${type}/${name}`)?.body ?? null),
64+
references: vi.fn(async () => []),
65+
layered: vi.fn(async (type: string, name: string) => {
66+
const effective = door.published.get(`${type}/${name}`)?.body ?? null;
67+
return { effective, code: null, overlay: null, overlayScope: null, editable: true, deletable: true, resettable: false, lock: 'none' };
68+
}),
69+
getDraft: vi.fn(async (type: string, name: string) => {
70+
const row = door.drafts.get(`${type}/${name}`);
71+
return row ? { type, name, item: JSON.parse(JSON.stringify(row.body)) } : null;
72+
}),
73+
// The door: a draft-mode first-write pin refuses a DRAFT row only, and
74+
// writes over a published one.
75+
save: vi.fn(
76+
async (type: string, name: string, body: Record<string, unknown>, opts: { packageId?: string | null; ifNoneMatch?: string } = {}) => {
77+
const key = `${type}/${name}`;
78+
if (opts?.ifNoneMatch === '*' && door.drafts.has(key)) {
79+
throw Object.assign(new Error(`${key}: this save asserted that no draft row exists (If-None-Match: *)`), {
80+
status: 409,
81+
code: 'METADATA_CONFLICT',
82+
});
83+
}
84+
door.drafts.set(key, { pkg: opts?.packageId ?? '', body: JSON.parse(JSON.stringify(body)) });
85+
return { type, name, item: body };
86+
},
87+
),
88+
publish: vi.fn(async () => ({ success: true })),
89+
reset: vi.fn(async () => ({})),
90+
};
91+
client.withPreviewDrafts = () => client;
92+
return client;
93+
});
94+
95+
vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
96+
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
97+
return {
98+
...mod,
99+
useMetadataClient: () => mockClient,
100+
useMetadataTypes: () => ({ loading: false, error: null, entries: [] }),
101+
};
102+
});
103+
104+
vi.mock('./packages-io', async (importOriginal) => {
105+
const mod = await importOriginal<typeof import('./packages-io')>();
106+
return {
107+
...mod,
108+
fetchPackages: vi.fn(async () => [{ id: PKG, name: 'Acme', writable: true, namespace: 'acme' }]),
109+
};
110+
});
111+
112+
vi.mock('@object-ui/react', async (importOriginal) => {
113+
const mod = await importOriginal<typeof import('@object-ui/react')>();
114+
return { ...mod, useAdapter: () => dataSource };
115+
});
116+
117+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), message: vi.fn() } }));
118+
vi.mock('../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPanel: () => null }));
119+
vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: () => null }));
120+
vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null }));
121+
vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => null }));
122+
123+
import { AutomationsPillar, DataPillar, StudioDesignSurface } from './StudioDesignSurface';
124+
import { createEmptyDataSource } from './__tests__/emptyDataSource';
125+
import { t, tFormat } from '../metadata-admin/i18n';
126+
127+
const dataSource = createEmptyDataSource();
128+
129+
window.matchMedia = ((query: string) => ({
130+
matches: false,
131+
media: query,
132+
onchange: null,
133+
addEventListener: () => {},
134+
removeEventListener: () => {},
135+
addListener: () => {},
136+
removeListener: () => {},
137+
dispatchEvent: () => false,
138+
})) as unknown as typeof window.matchMedia;
139+
140+
(globalThis as { ResizeObserver?: unknown }).ResizeObserver =
141+
(globalThis as { ResizeObserver?: unknown }).ResizeObserver ??
142+
class {
143+
observe() {}
144+
unobserve() {}
145+
disconnect() {}
146+
};
147+
148+
const en = (key: string) => t(key, 'en');
149+
const save = mockClient.save as ReturnType<typeof vi.fn>;
150+
const getDraft = mockClient.getDraft as ReturnType<typeof vi.fn>;
151+
152+
function publish(type: string, pkg: string, body: Record<string, unknown>) {
153+
door.published.set(`${type}/${String(body.name)}`, { pkg, body } satisfies Row);
154+
}
155+
156+
/** The published body, as a deep copy, so "unchanged" compares against what was seeded. */
157+
const publishedBody = (type: string, name: string) => JSON.parse(JSON.stringify(door.published.get(`${type}/${name}`)!.body));
158+
159+
const FLOW_ALPHA = { name: 'alpha_intake', label: 'Alpha intake', type: 'autolaunched', nodes: [], edges: [] };
160+
const FLOW_ESCALATE = {
161+
name: 'escalate',
162+
label: 'Escalate',
163+
type: 'autolaunched',
164+
status: 'active',
165+
nodes: [
166+
{ id: 'start', type: 'start', label: 'Start', config: { triggerType: 'record-after-update', objectName: 'acme_ticket' } },
167+
{ id: 'notify', type: 'notify', label: 'Notify' },
168+
{ id: 'end', type: 'end', label: 'End' },
169+
],
170+
edges: [],
171+
};
172+
const OBJ_ACCOUNT = { name: 'acme_account', label: 'Account', fields: { name: { type: 'text', label: 'Name' } } };
173+
const OBJ_TICKET = { name: 'acme_ticket', label: 'Ticket', fields: { subject: { type: 'text', label: 'Subject' } } };
174+
const APP_FIELD = { name: 'field_service', label: 'Field Service', active: true, navigation: [{ id: 'nav_x', type: 'object', objectName: 'other_x' }] };
175+
176+
beforeEach(() => {
177+
door.published.clear();
178+
door.drafts.clear();
179+
window.confirm = vi.fn(() => true) as unknown as typeof window.confirm;
180+
vi.stubGlobal(
181+
'fetch',
182+
vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })),
183+
);
184+
});
185+
186+
afterEach(() => {
187+
cleanup();
188+
vi.clearAllMocks();
189+
vi.unstubAllGlobals();
190+
});
191+
192+
/** Fill the create dialog's label (the identifier derives from it) and submit. */
193+
function submitCreate(dialog: HTMLElement, label: string) {
194+
fireEvent.change(dialog.querySelectorAll('input')[0], { target: { value: label } });
195+
fireEvent.click(within(dialog).getByRole('button', { name: en('engine.studio.createDraft') }));
196+
}
197+
198+
describe('objectstack#26917 — Automations: New automation over a taken identifier', () => {
199+
async function openNewAutomation() {
200+
render(
201+
<MemoryRouter initialEntries={[`/studio/${PKG}/automations`]}>
202+
<AutomationsPillar packageId={PKG} />
203+
</MemoryRouter>,
204+
);
205+
await screen.findByTestId('auto-rail-row-alpha_intake', undefined, { timeout: 8000 });
206+
fireEvent.click(screen.getByTitle(en('engine.studio.auto.newTitle')));
207+
const dialog = await screen.findByRole('dialog');
208+
// The manual preset: the record ones hold Create until an object is named.
209+
fireEvent.click(within(dialog).getByTestId('create-flow-preset-manual'));
210+
return dialog;
211+
}
212+
213+
it("a published flow on this package's rail: refused, nothing written, and Open opens it", async () => {
214+
publish('flow', PKG, FLOW_ALPHA);
215+
publish('flow', PKG, FLOW_ESCALATE);
216+
const before = publishedBody('flow', 'escalate');
217+
const dialog = await openNewAutomation();
218+
submitCreate(dialog, 'Escalate');
219+
220+
const taken = await within(dialog).findByTestId('flow-create-taken');
221+
expect(taken).toHaveTextContent(
222+
tFormat('engine.studio.auto.createTaken', 'en', { name: 'escalate', label: 'Escalate' }),
223+
);
224+
expect(save).not.toHaveBeenCalled();
225+
expect(door.drafts.size).toBe(0);
226+
expect(door.published.get('flow/escalate')!.body).toEqual(before);
227+
228+
expect(getDraft.mock.calls.some(([, name]) => name === 'escalate')).toBe(false);
229+
fireEvent.click(within(taken).getByTestId('flow-create-open-existing'));
230+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
231+
await waitFor(() => expect(getDraft.mock.calls.some(([, name]) => name === 'escalate')).toBe(true));
232+
expect(save).not.toHaveBeenCalled();
233+
});
234+
235+
it('a published flow another package holds: refused, nothing written, and no Open', async () => {
236+
publish('flow', PKG, FLOW_ALPHA);
237+
publish('flow', OTHER_PKG, FLOW_ESCALATE);
238+
const before = publishedBody('flow', 'escalate');
239+
const dialog = await openNewAutomation();
240+
submitCreate(dialog, 'Escalate');
241+
242+
const taken = await within(dialog).findByTestId('flow-create-taken');
243+
expect(taken).toHaveTextContent(
244+
tFormat('engine.studio.auto.createTakenElsewhere', 'en', { name: 'escalate', label: 'Escalate' }),
245+
);
246+
expect(within(taken).queryByTestId('flow-create-open-existing')).toBeNull();
247+
expect(save).not.toHaveBeenCalled();
248+
expect(door.drafts.size).toBe(0);
249+
expect(door.published.get('flow/escalate')!.body).toEqual(before);
250+
});
251+
252+
it('a fresh identifier is created as before, under the first-write pin', async () => {
253+
publish('flow', PKG, FLOW_ALPHA);
254+
publish('flow', OTHER_PKG, FLOW_ESCALATE);
255+
const dialog = await openNewAutomation();
256+
submitCreate(dialog, 'Close stale');
257+
258+
await waitFor(() => expect(save).toHaveBeenCalledTimes(1));
259+
const [type, name, , opts] = save.mock.calls[0]!;
260+
expect([type, name, opts]).toEqual([
261+
'flow',
262+
'close_stale',
263+
{ mode: 'draft', packageId: PKG, ifNoneMatch: '*', surface: 'studio' },
264+
]);
265+
expect(door.drafts.has('flow/close_stale')).toBe(true);
266+
expect(screen.queryByTestId('flow-create-taken')).toBeNull();
267+
});
268+
});
269+
270+
describe('objectstack#26917 — Data: New object over a taken identifier', () => {
271+
async function openNewObject() {
272+
render(
273+
<MemoryRouter initialEntries={[`/studio/${PKG}/data`]}>
274+
<DataPillar packageId={PKG} />
275+
</MemoryRouter>,
276+
);
277+
fireEvent.click(await screen.findByRole('button', { name: en('engine.studio.data.newObject') }, { timeout: 8000 }));
278+
const dialog = await screen.findByRole('dialog');
279+
// The identifier is stored with the package's namespace prefix
280+
// (`prefixObjectName`): wait until the dialog previews it.
281+
await waitFor(() => {
282+
fireEvent.change(dialog.querySelectorAll('input')[0], { target: { value: 'Ticket' } });
283+
expect(within(dialog).getByTestId('create-item-stored-name')).toHaveTextContent('acme_ticket');
284+
});
285+
return dialog;
286+
}
287+
288+
it('a published object another package holds: refused, nothing written, and no Open', async () => {
289+
publish('object', PKG, OBJ_ACCOUNT);
290+
publish('object', OTHER_PKG, OBJ_TICKET);
291+
const before = publishedBody('object', 'acme_ticket');
292+
const dialog = await openNewObject();
293+
submitCreate(dialog, 'Ticket');
294+
295+
const taken = await within(dialog).findByTestId('object-create-taken');
296+
expect(taken).toHaveTextContent(
297+
tFormat('engine.studio.data.createTakenElsewhere', 'en', { name: 'acme_ticket', label: 'Ticket' }),
298+
);
299+
expect(within(taken).queryByTestId('object-create-open-existing')).toBeNull();
300+
expect(save).not.toHaveBeenCalled();
301+
expect(door.drafts.size).toBe(0);
302+
expect(door.published.get('object/acme_ticket')!.body).toEqual(before);
303+
});
304+
305+
it("an object on this package's rail: refused, nothing written, and Open opens it", async () => {
306+
publish('object', PKG, OBJ_ACCOUNT);
307+
publish('object', PKG, OBJ_TICKET);
308+
const dialog = await openNewObject();
309+
submitCreate(dialog, 'Ticket');
310+
311+
const taken = await within(dialog).findByTestId('object-create-taken');
312+
expect(taken).toHaveTextContent(
313+
tFormat('engine.studio.data.createTaken', 'en', { name: 'acme_ticket', label: 'Ticket' }),
314+
);
315+
expect(save).not.toHaveBeenCalled();
316+
317+
fireEvent.click(within(taken).getByTestId('object-create-open-existing'));
318+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
319+
await waitFor(() => expect(getDraft.mock.calls.some(([type, name]) => type === 'object' && name === 'acme_ticket')).toBe(true));
320+
expect(save).not.toHaveBeenCalled();
321+
});
322+
323+
it('a fresh identifier is created as before, under the first-write pin', async () => {
324+
publish('object', PKG, OBJ_ACCOUNT);
325+
const dialog = await openNewObject();
326+
submitCreate(dialog, 'Ticket');
327+
328+
await waitFor(() => expect(save).toHaveBeenCalledTimes(1));
329+
const [type, name, , opts] = save.mock.calls[0]!;
330+
expect([type, name, opts]).toEqual([
331+
'object',
332+
'acme_ticket',
333+
{ mode: 'draft', packageId: PKG, ifNoneMatch: '*', surface: 'studio' },
334+
]);
335+
});
336+
});
337+
338+
describe('objectstack#26917 — Create app over a taken identifier', () => {
339+
async function openCreateApp() {
340+
render(
341+
<MemoryRouter initialEntries={[`/studio/${PKG}/automations`]}>
342+
<Routes>
343+
<Route path="/studio/:packageId/:tab" element={<StudioDesignSurface />} />
344+
</Routes>
345+
</MemoryRouter>,
346+
);
347+
const toolbar = await screen.findByTestId('studio-shell-header', undefined, { timeout: 8000 });
348+
await waitFor(() => expect(within(toolbar).getByRole('button', { name: en('engine.studio.app.create') })).toBeInTheDocument());
349+
fireEvent.click(within(toolbar).getByRole('button', { name: en('engine.studio.app.create') }));
350+
return await screen.findByRole('dialog');
351+
}
352+
353+
it('a published app another package holds: refused, nothing written', async () => {
354+
publish('app', OTHER_PKG, APP_FIELD);
355+
const before = publishedBody('app', 'field_service');
356+
const dialog = await openCreateApp();
357+
submitCreate(dialog, 'Field Service');
358+
359+
const taken = await within(dialog).findByTestId('app-create-taken');
360+
expect(taken).toHaveTextContent(
361+
tFormat('engine.studio.app.createTaken', 'en', { name: 'field_service', label: 'Field Service' }),
362+
);
363+
expect(within(taken).queryByTestId('app-create-open-existing')).toBeNull();
364+
expect(save).not.toHaveBeenCalled();
365+
expect(door.drafts.size).toBe(0);
366+
expect(door.published.get('app/field_service')!.body).toEqual(before);
367+
});
368+
369+
it('a fresh identifier is created as before, under the first-write pin', async () => {
370+
publish('app', OTHER_PKG, APP_FIELD);
371+
const dialog = await openCreateApp();
372+
submitCreate(dialog, 'Dispatch');
373+
374+
await waitFor(() => expect(save).toHaveBeenCalledTimes(1));
375+
const [type, name, , opts] = save.mock.calls[0]!;
376+
expect([type, name, opts]).toEqual([
377+
'app',
378+
'dispatch',
379+
{ mode: 'draft', packageId: PKG, ifNoneMatch: '*', surface: 'studio' },
380+
]);
381+
});
382+
});

0 commit comments

Comments
 (0)