diff --git a/.changeset/26917-studio-create-taken-id.md b/.changeset/26917-studio-create-taken-id.md new file mode 100644 index 00000000000..520aaf2ffc6 --- /dev/null +++ b/.changeset/26917-studio-create-taken-id.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +fix(app-shell): Studio's Automations, App and Data creates refuse an identifier a published or draft item of that type already holds + +Clause-②: no +Metadata-protocol: none + +- **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. +- **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. +- **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. diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.createTaken-26917.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.createTaken-26917.test.tsx new file mode 100644 index 00000000000..15729dea5c4 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.createTaken-26917.test.tsx @@ -0,0 +1,382 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26917 — Studio's Automations, App and Data creates over an + * identifier a published item of that type already holds. + * + * Each create saves its blank skeleton under the first-write pin + * (`If-None-Match: *` with `?mode=draft`), which asserts that no DRAFT row + * exists: the spec's `parentVersion: null` is lifecycle-scoped. Over a + * PUBLISHED flow, app or object of the same identifier the door therefore + * wrote the skeleton as that item's pending draft, and publishing the package + * would have replaced the live item with the blank one. The Access create was + * fixed the same way by objectstack#26836. + * + * - Automations: a published flow on this rail is refused with "Open"; one + * another package holds is refused without it; nothing is written either way. + * - Data: the same for objects (the namespace-prefixed identifier). + * - App: an app another package holds is refused (Create app is offered only + * to a package with no app, so there is nothing of its own to open). + * - A fresh identifier is created as before, with the pin. + * + * The REAL pillars and dialogs, 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. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +const PKG = 'com.acme.app'; +const OTHER_PKG = 'com.other.app'; + +type Row = { pkg: string; body: Record }; + +const door = vi.hoisted(() => ({ + /** `type/name` → the published row. */ + published: new Map }>(), + /** `type/name` → the pending draft row. */ + drafts: new Map }>(), +})); + +const mockClient = vi.hoisted(() => { + const rows = (map: Map }>, type: string | undefined, pkg?: string | null) => + [...map.entries()] + .filter(([key, row]) => (!type || key.startsWith(`${type}/`)) && (!pkg || row.pkg === pkg)) + .map(([key, row]) => ({ key, ...row })); + const client: Record = { + list: vi.fn(async (type: string, opts: { packageId?: string | null } = {}) => + rows(door.published, type, opts?.packageId).map((r) => ({ ...r.body, _packageId: r.pkg })), + ), + listDrafts: vi.fn(async (opts: { packageId?: string | null; type?: string } = {}) => + rows(door.drafts, opts?.type, opts?.packageId).map((r) => ({ + type: r.key.split('/')[0], + name: r.body.name, + label: r.body.label, + packageId: r.pkg, + })), + ), + listTypes: vi.fn(async () => ({ entries: [] })), + get: vi.fn(async (type: string, name: string) => door.published.get(`${type}/${name}`)?.body ?? null), + references: vi.fn(async () => []), + layered: vi.fn(async (type: string, name: string) => { + const effective = door.published.get(`${type}/${name}`)?.body ?? null; + return { effective, code: null, overlay: null, overlayScope: null, editable: true, deletable: true, resettable: false, lock: 'none' }; + }), + getDraft: vi.fn(async (type: string, name: string) => { + const row = door.drafts.get(`${type}/${name}`); + return row ? { type, name, item: JSON.parse(JSON.stringify(row.body)) } : null; + }), + // The door: a draft-mode first-write pin refuses a DRAFT row only, and + // writes over a published one. + save: vi.fn( + async (type: string, name: string, body: Record, opts: { packageId?: string | null; ifNoneMatch?: string } = {}) => { + const key = `${type}/${name}`; + if (opts?.ifNoneMatch === '*' && door.drafts.has(key)) { + throw Object.assign(new Error(`${key}: this save asserted that no draft row exists (If-None-Match: *)`), { + status: 409, + code: 'METADATA_CONFLICT', + }); + } + door.drafts.set(key, { pkg: opts?.packageId ?? '', body: JSON.parse(JSON.stringify(body)) }); + return { type, name, item: body }; + }, + ), + publish: vi.fn(async () => ({ success: true })), + reset: vi.fn(async () => ({})), + }; + client.withPreviewDrafts = () => client; + return client; +}); + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useMetadataClient: () => mockClient, + useMetadataTypes: () => ({ loading: false, error: null, entries: [] }), + }; +}); + +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + fetchPackages: vi.fn(async () => [{ id: PKG, name: 'Acme', writable: true, namespace: 'acme' }]), + }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => dataSource }; +}); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), message: vi.fn() } })); +vi.mock('../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPanel: () => null })); +vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: () => null })); +vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null })); +vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => null })); + +import { AutomationsPillar, DataPillar, StudioDesignSurface } from './StudioDesignSurface'; +import { createEmptyDataSource } from './__tests__/emptyDataSource'; +import { t, tFormat } from '../metadata-admin/i18n'; + +const dataSource = createEmptyDataSource(); + +window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, +})) as unknown as typeof window.matchMedia; + +(globalThis as { ResizeObserver?: unknown }).ResizeObserver = + (globalThis as { ResizeObserver?: unknown }).ResizeObserver ?? + class { + observe() {} + unobserve() {} + disconnect() {} + }; + +const en = (key: string) => t(key, 'en'); +const save = mockClient.save as ReturnType; +const getDraft = mockClient.getDraft as ReturnType; + +function publish(type: string, pkg: string, body: Record) { + door.published.set(`${type}/${String(body.name)}`, { pkg, body } satisfies Row); +} + +/** The published body, as a deep copy, so "unchanged" compares against what was seeded. */ +const publishedBody = (type: string, name: string) => JSON.parse(JSON.stringify(door.published.get(`${type}/${name}`)!.body)); + +const FLOW_ALPHA = { name: 'alpha_intake', label: 'Alpha intake', type: 'autolaunched', nodes: [], edges: [] }; +const FLOW_ESCALATE = { + name: 'escalate', + label: 'Escalate', + type: 'autolaunched', + status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: { triggerType: 'record-after-update', objectName: 'acme_ticket' } }, + { id: 'notify', type: 'notify', label: 'Notify' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [], +}; +const OBJ_ACCOUNT = { name: 'acme_account', label: 'Account', fields: { name: { type: 'text', label: 'Name' } } }; +const OBJ_TICKET = { name: 'acme_ticket', label: 'Ticket', fields: { subject: { type: 'text', label: 'Subject' } } }; +const APP_FIELD = { name: 'field_service', label: 'Field Service', active: true, navigation: [{ id: 'nav_x', type: 'object', objectName: 'other_x' }] }; + +beforeEach(() => { + door.published.clear(); + door.drafts.clear(); + window.confirm = vi.fn(() => true) as unknown as typeof window.confirm; + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })), + ); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +/** Fill the create dialog's label (the identifier derives from it) and submit. */ +function submitCreate(dialog: HTMLElement, label: string) { + fireEvent.change(dialog.querySelectorAll('input')[0], { target: { value: label } }); + fireEvent.click(within(dialog).getByRole('button', { name: en('engine.studio.createDraft') })); +} + +describe('objectstack#26917 — Automations: New automation over a taken identifier', () => { + async function openNewAutomation() { + render( + + + , + ); + await screen.findByTestId('auto-rail-row-alpha_intake', undefined, { timeout: 8000 }); + fireEvent.click(screen.getByTitle(en('engine.studio.auto.newTitle'))); + const dialog = await screen.findByRole('dialog'); + // The manual preset: the record ones hold Create until an object is named. + fireEvent.click(within(dialog).getByTestId('create-flow-preset-manual')); + return dialog; + } + + it("a published flow on this package's rail: refused, nothing written, and Open opens it", async () => { + publish('flow', PKG, FLOW_ALPHA); + publish('flow', PKG, FLOW_ESCALATE); + const before = publishedBody('flow', 'escalate'); + const dialog = await openNewAutomation(); + submitCreate(dialog, 'Escalate'); + + const taken = await within(dialog).findByTestId('flow-create-taken'); + expect(taken).toHaveTextContent( + tFormat('engine.studio.auto.createTaken', 'en', { name: 'escalate', label: 'Escalate' }), + ); + expect(save).not.toHaveBeenCalled(); + expect(door.drafts.size).toBe(0); + expect(door.published.get('flow/escalate')!.body).toEqual(before); + + expect(getDraft.mock.calls.some(([, name]) => name === 'escalate')).toBe(false); + fireEvent.click(within(taken).getByTestId('flow-create-open-existing')); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + await waitFor(() => expect(getDraft.mock.calls.some(([, name]) => name === 'escalate')).toBe(true)); + expect(save).not.toHaveBeenCalled(); + }); + + it('a published flow another package holds: refused, nothing written, and no Open', async () => { + publish('flow', PKG, FLOW_ALPHA); + publish('flow', OTHER_PKG, FLOW_ESCALATE); + const before = publishedBody('flow', 'escalate'); + const dialog = await openNewAutomation(); + submitCreate(dialog, 'Escalate'); + + const taken = await within(dialog).findByTestId('flow-create-taken'); + expect(taken).toHaveTextContent( + tFormat('engine.studio.auto.createTakenElsewhere', 'en', { name: 'escalate', label: 'Escalate' }), + ); + expect(within(taken).queryByTestId('flow-create-open-existing')).toBeNull(); + expect(save).not.toHaveBeenCalled(); + expect(door.drafts.size).toBe(0); + expect(door.published.get('flow/escalate')!.body).toEqual(before); + }); + + it('a fresh identifier is created as before, under the first-write pin', async () => { + publish('flow', PKG, FLOW_ALPHA); + publish('flow', OTHER_PKG, FLOW_ESCALATE); + const dialog = await openNewAutomation(); + submitCreate(dialog, 'Close stale'); + + await waitFor(() => expect(save).toHaveBeenCalledTimes(1)); + const [type, name, , opts] = save.mock.calls[0]!; + expect([type, name, opts]).toEqual([ + 'flow', + 'close_stale', + { mode: 'draft', packageId: PKG, ifNoneMatch: '*', surface: 'studio' }, + ]); + expect(door.drafts.has('flow/close_stale')).toBe(true); + expect(screen.queryByTestId('flow-create-taken')).toBeNull(); + }); +}); + +describe('objectstack#26917 — Data: New object over a taken identifier', () => { + async function openNewObject() { + render( + + + , + ); + fireEvent.click(await screen.findByRole('button', { name: en('engine.studio.data.newObject') }, { timeout: 8000 })); + const dialog = await screen.findByRole('dialog'); + // The identifier is stored with the package's namespace prefix + // (`prefixObjectName`): wait until the dialog previews it. + await waitFor(() => { + fireEvent.change(dialog.querySelectorAll('input')[0], { target: { value: 'Ticket' } }); + expect(within(dialog).getByTestId('create-item-stored-name')).toHaveTextContent('acme_ticket'); + }); + return dialog; + } + + it('a published object another package holds: refused, nothing written, and no Open', async () => { + publish('object', PKG, OBJ_ACCOUNT); + publish('object', OTHER_PKG, OBJ_TICKET); + const before = publishedBody('object', 'acme_ticket'); + const dialog = await openNewObject(); + submitCreate(dialog, 'Ticket'); + + const taken = await within(dialog).findByTestId('object-create-taken'); + expect(taken).toHaveTextContent( + tFormat('engine.studio.data.createTakenElsewhere', 'en', { name: 'acme_ticket', label: 'Ticket' }), + ); + expect(within(taken).queryByTestId('object-create-open-existing')).toBeNull(); + expect(save).not.toHaveBeenCalled(); + expect(door.drafts.size).toBe(0); + expect(door.published.get('object/acme_ticket')!.body).toEqual(before); + }); + + it("an object on this package's rail: refused, nothing written, and Open opens it", async () => { + publish('object', PKG, OBJ_ACCOUNT); + publish('object', PKG, OBJ_TICKET); + const dialog = await openNewObject(); + submitCreate(dialog, 'Ticket'); + + const taken = await within(dialog).findByTestId('object-create-taken'); + expect(taken).toHaveTextContent( + tFormat('engine.studio.data.createTaken', 'en', { name: 'acme_ticket', label: 'Ticket' }), + ); + expect(save).not.toHaveBeenCalled(); + + fireEvent.click(within(taken).getByTestId('object-create-open-existing')); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + await waitFor(() => expect(getDraft.mock.calls.some(([type, name]) => type === 'object' && name === 'acme_ticket')).toBe(true)); + expect(save).not.toHaveBeenCalled(); + }); + + it('a fresh identifier is created as before, under the first-write pin', async () => { + publish('object', PKG, OBJ_ACCOUNT); + const dialog = await openNewObject(); + submitCreate(dialog, 'Ticket'); + + await waitFor(() => expect(save).toHaveBeenCalledTimes(1)); + const [type, name, , opts] = save.mock.calls[0]!; + expect([type, name, opts]).toEqual([ + 'object', + 'acme_ticket', + { mode: 'draft', packageId: PKG, ifNoneMatch: '*', surface: 'studio' }, + ]); + }); +}); + +describe('objectstack#26917 — Create app over a taken identifier', () => { + async function openCreateApp() { + render( + + + } /> + + , + ); + const toolbar = await screen.findByTestId('studio-shell-header', undefined, { timeout: 8000 }); + await waitFor(() => expect(within(toolbar).getByRole('button', { name: en('engine.studio.app.create') })).toBeInTheDocument()); + fireEvent.click(within(toolbar).getByRole('button', { name: en('engine.studio.app.create') })); + return await screen.findByRole('dialog'); + } + + it('a published app another package holds: refused, nothing written', async () => { + publish('app', OTHER_PKG, APP_FIELD); + const before = publishedBody('app', 'field_service'); + const dialog = await openCreateApp(); + submitCreate(dialog, 'Field Service'); + + const taken = await within(dialog).findByTestId('app-create-taken'); + expect(taken).toHaveTextContent( + tFormat('engine.studio.app.createTaken', 'en', { name: 'field_service', label: 'Field Service' }), + ); + expect(within(taken).queryByTestId('app-create-open-existing')).toBeNull(); + expect(save).not.toHaveBeenCalled(); + expect(door.drafts.size).toBe(0); + expect(door.published.get('app/field_service')!.body).toEqual(before); + }); + + it('a fresh identifier is created as before, under the first-write pin', async () => { + publish('app', OTHER_PKG, APP_FIELD); + const dialog = await openCreateApp(); + submitCreate(dialog, 'Dispatch'); + + await waitFor(() => expect(save).toHaveBeenCalledTimes(1)); + const [type, name, , opts] = save.mock.calls[0]!; + expect([type, name, opts]).toEqual([ + 'app', + 'dispatch', + { mode: 'draft', packageId: PKG, ifNoneMatch: '*', surface: 'studio' }, + ]); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 2ed269061bd..7c7d299b02a 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -452,6 +452,71 @@ function confirmDropUnsavedWork(unsaved: boolean, locale: string): boolean { return !unsaved || window.confirm(t('engine.edit.unsavedLeaveConfirm', locale)); } +/** + * objectstack#26836, objectstack#26917 — the item of `type` that already holds + * `name`, published or as a draft, in this package or any other; `null` when + * the identifier is free. Every Studio create reads it before its save and + * refuses a taken identifier, writing nothing. + * + * The create's first-write pin (`ifNoneMatch: '*'` with `mode: 'draft'`) + * asserts "no DRAFT row" only — the spec's `parentVersion: null` is + * lifecycle-scoped — so over a PUBLISHED item it would write the blank + * skeleton as that item's pending draft, and publishing the package would + * replace the live item with it. The pin stays on the save for a draft that + * lands between this read and the write; an item published in that window is + * not refused by it. + */ +async function findTakenIdentifier( + client: Pick, + type: string, + name: string, + locale: string, +): Promise<{ name: string; label: string } | null> { + const [published, drafts] = await Promise.all([ + client.list>(type), + client.listDrafts({ type }) as Promise>, + ]); + const publishedRow = (published ?? []).find((row) => row?.name === name); + const draftRow = (drafts ?? []).find((row) => row?.name === name); + if (!publishedRow && !draftRow) return null; + const label = navItemLabelText((publishedRow?.label as I18nLabel | undefined) ?? draftRow?.label ?? undefined, locale); + return { name, label: label.trim() || name }; +} + +/** + * A create refused over a taken identifier (`findTakenIdentifier`), in the + * create dialog: the sentence naming the item that holds it and, when that item + * is on the pillar's own rail, a way to open it instead. + */ +function CreateTakenNotice({ + scope, + sentence, + openLabel, + onOpen, +}: { + /** The pillar's test-id prefix: `${scope}-create-taken`. */ + scope: string; + sentence: string; + openLabel?: string; + onOpen?: () => void; +}): React.ReactElement { + return ( +
+

{sentence}

+ {onOpen && openLabel && ( + + )} +
+ ); +} + /** * objectui#11786 — the line a pillar shows, in place of a refusal, while its * autosave holds an incomplete-but-normal edit: what the edit still needs, that @@ -1651,6 +1716,11 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React const [appBusy, setAppBusy] = React.useState(false); const [appErr, setAppErr] = React.useState(null); const [appDraftPending, setAppDraftPending] = React.useState(null); + // objectstack#26917 — a create refused because an app already holds the + // identifier (published or as a draft, in any package): the app's label, for + // the dialog's sentence. Create app is offered only to a package with no app, + // so the holder is never one this package can open instead. + const [appTaken, setAppTaken] = React.useState<{ name: string; label: string } | null>(null); // Scaffold the new app's navigation from the package's objects (default on) — // otherwise a fresh app has zero menu items and every object must be wired by // hand in the Interfaces pillar (objectui#2262). @@ -1684,10 +1754,21 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React if (packageId === null) return; setAppBusy(true); setAppErr(null); + setAppTaken(null); try { + // A name an app already holds, published or as a draft, in any + // package, is refused here and nothing is written: over a published + // app the first-write pin below would write this skeleton as its + // pending draft (`findTakenIdentifier`). + const taken = await findTakenIdentifier(shellClient, 'app', name, locale); + if (taken) { + setAppTaken(taken); + return; + } const navObjects = appAddObjects ? await loadPackageObjects().catch(() => []) : []; - // objectui#11773 — a create is the first-write pin: refused where the - // name already holds a draft, instead of written over it. + // objectui#11773 — and the door refuses a draft row too (the + // first-write pin), for one that lands between the check above and + // this save. await shellClient.save( 'app', name, @@ -2263,7 +2344,10 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React { + setAppCreating(open); + if (!open) setAppTaken(null); + }} title={t('engine.studio.app.create', locale)} labelFieldLabel={t('engine.studio.app.nameLabel', locale)} labelPlaceholder={t('engine.studio.app.namePlaceholder', locale)} @@ -2276,15 +2360,23 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React locale={locale} onSubmit={({ label, name }) => void doCreateApp(label, name)} extra={ - + <> + {appTaken && ( + + )} + + } /> @@ -5667,8 +5759,12 @@ export function DataPillar({ // an effect on `creating` — the effect spelling re-renders the dialog a // second time on every open purely to undo a previous session's pick. const [createOwd, setCreateOwd] = React.useState(OWD_DEFAULT); + // objectstack#26917 — a create refused because the identifier is taken: + // which object holds it, and whether it is on this rail, so it can be opened. + const [createTaken, setCreateTaken] = React.useState<{ name: string; label: string; here: boolean } | null>(null); const openCreateDialog = React.useCallback(() => { setError(null); + setCreateTaken(null); setCreateOwd(OWD_DEFAULT); setCreating(true); }, []); @@ -5991,16 +6087,28 @@ export function DataPillar({ // Auto-prefix with the package namespace (framework#2694) so a prefix-less // object can't be authored; the rule lives in packages-io/spec. const name = prefixObjectName(rawName, namespace); - if (objects.some((o) => o.name === name)) { - setError({ message: tFormat('engine.studio.data.idExists', locale, { name }) }); - return; - } setCreateBusy(true); setError(null); + setCreateTaken(null); try { + // A name an object already holds, published or as a draft, on this + // rail or in any other package, is refused here and nothing is + // written: over a published object the first-write pin below would + // write this blank skeleton as its pending draft + // (`findTakenIdentifier`). The namespace prefix makes a clash with + // another package's object rarer, not impossible. + const onRail = objects.find((o) => o.name === name); + const taken = onRail + ? { name, label: onRail.label || name } + : await findTakenIdentifier(client, 'object', name, locale); + if (taken) { + setCreateTaken({ ...taken, here: onRail !== undefined }); + return; + } const body = buildObjectSkeleton(name, label, t('engine.studio.data.nameFieldLabel', locale), sharingModel); - // objectui#11773 — a create is the first-write pin: refused where the - // name already holds a draft, instead of written over it. + // objectui#11773 — and the door refuses a draft row too (the + // first-write pin), for one that lands between the check above and + // this save. await client.save('object', name, body, { mode: 'draft', packageId, ifNoneMatch: '*', surface: 'studio' }); const surface: Surface = { type: 'object', name, label }; setObjects((prev) => [...prev, surface]); @@ -6069,6 +6177,16 @@ export function DataPillar({ }); // Only the open object's own buffer is held (objectui#11272). const objHeld = objLoaded ? objIncomplete : null; + // objectstack#26917 — the refusal's way out: the object holding the + // identifier is on this rail, so it opens instead of a second one being + // made. The New button already asked about a held edit (objectstack#26845). + const openTakenObject = (name: string) => { + const object = objects.find((o) => o.name === name); + if (!object) return; + setCreating(false); + setCreateTaken(null); + setCurrent(object); + }; // objectui#12725 — the copilot's mark on the selected field: its key path is // `["fields", NAME]` in the object body, and only a `copilot` write answers. const fieldCopilotMark = @@ -6920,34 +7038,48 @@ export function DataPillar({ // shows `repairs_repair_ticket`, not the bare identifier. storedName={(identifier) => prefixObjectName(identifier, namespace)} extra={ - /* Record sharing (OWD) — the third thing `New object` must ask for - (`7a90afdf9`). Without it the object saves as a draft happily and - is then REFUSED at Publish → Publish all by `security-owd-unset`, a - wall the author meets only after building the whole object. The - gloss is the SAME string the Settings tab shows for each model, so - the two surfaces cannot describe one baseline two ways. - - `controlled_by_parent` is absent on purpose — see OWD_CREATE_MODELS: - a just-created object has no master-detail field for it to derive - access from. */ - + <> + {createTaken && ( + openTakenObject(createTaken.name) : undefined} + /> + )} + {/* Record sharing (OWD) — the third thing `New object` must ask for + (`7a90afdf9`). Without it the object saves as a draft happily and + is then REFUSED at Publish → Publish all by `security-owd-unset`, a + wall the author meets only after building the whole object. The + gloss is the SAME string the Settings tab shows for each model, so + the two surfaces cannot describe one baseline two ways. + + `controlled_by_parent` is absent on purpose — see OWD_CREATE_MODELS: + a just-created object has no master-detail field for it to derive + access from. */} + + } onSubmit={({ label, name }) => void doCreateObject(label, name, createOwd)} /> @@ -7353,6 +7485,9 @@ export function AutomationsPillar({ // shows while Create holds it. const [newChoice, setNewChoice] = React.useState(FLOW_PRESETS[0]); const [newNeeds, setNewNeeds] = React.useState(null); + // objectstack#26917 — a create refused because the identifier is taken: + // which flow holds it, and whether it is on this rail, so it can be opened. + const [createTaken, setCreateTaken] = React.useState<{ name: string; label: string; here: boolean } | null>(null); const newChoiceGroup = React.useId(); // objectui#12303 — the read-only reason the header's status switch is described by. const toggleReadOnlyReasonId = React.useId(); @@ -7485,7 +7620,17 @@ export function AutomationsPillar({ async (label: string, name: string, trigger: NewFlowTrigger | null) => { setCreateBusy(true); setError(null); + setCreateTaken(null); try { + // A name a flow already holds, published or as a draft, in this + // package or any other, is refused here and nothing is written: over + // a published flow the first-write pin below would write this blank + // skeleton as its pending draft (`findTakenIdentifier`). + const taken = await findTakenIdentifier(client, 'flow', name, locale); + if (taken) { + setCreateTaken({ ...taken, here: flows.some((f) => f.name === name) }); + return; + } // Minimal valid, autolaunched skeleton: start → end. The designer fills in // the trigger + nodes; publishing it is a separate, user-initiated step. // @@ -7508,8 +7653,9 @@ export function AutomationsPillar({ }, trigger, ); - // objectui#11773 — a create is the first-write pin: refused where the - // name already holds a draft, instead of written over it. + // objectui#11773 — and the door refuses a draft row too (the + // first-write pin), for one that lands between the check above and + // this save. await client.save('flow', name, skeleton, { mode: 'draft', packageId: draftPackageId, ifNoneMatch: '*', surface: 'studio' }); const item: Surface = { type: 'flow', name, label }; setFlows((fs) => [...fs.filter((f) => f.name !== name), item]); @@ -7527,7 +7673,7 @@ export function AutomationsPillar({ setCreateBusy(false); } }, - [client, draftPackageId, onDraftSaved, locale], + [client, draftPackageId, onDraftSaved, locale, flows], ); React.useEffect(() => { @@ -7698,9 +7844,20 @@ export function AutomationsPillar({ setNewTriggerObject(''); setNewChoice(FLOW_PRESETS[0]); setNewNeeds(null); + setCreateTaken(null); setCreating(true); } : undefined; + // objectstack#26917 — the refusal's way out: the flow holding the identifier + // is on this rail, so it opens instead of a second one being made. The New + // button already asked about a held step (objectstack#26845). + const openTakenFlow = (name: string) => { + const flow = flows.find((f) => f.name === name); + if (!flow) return; + setCreating(false); + setCreateTaken(null); + setCurrent(flow); + }; // objectui#12514 — a listed package with no flow: one centered block in the // content column, and no aside while there is nothing to select. const flowsEmpty = listed && !error && flows.length === 0; @@ -8318,91 +8475,105 @@ export function AutomationsPillar({ node's own Object field, and Create holds it until one is named. *Advanced* is the trigger form below, unchanged: it writes what it wrote. */ -
- {t('engine.studio.auto.presets', locale)} - {FLOW_PRESETS.map((p) => ( -
+ <> + {createTaken && ( + openTakenFlow(createTaken.name) : undefined} + /> + )} +
+ {t('engine.studio.auto.presets', locale)} + {FLOW_PRESETS.map((p) => ( +
+ + {newChoice === p && startTrigger.object?.showWhen?.equals.includes(p.triggerType) && ( +
+ { + setNewTriggerObject(v); + setNewNeeds(null); + }} + locale={locale} + /> +
+ )} +
+ ))} +
- {newChoice === p && startTrigger.object?.showWhen?.equals.includes(p.triggerType) && ( -
- { - setNewTriggerObject(v); - setNewNeeds(null); - }} - locale={locale} - /> + {/* objectui#11788 — the trigger, asked for here rather than found + later inside the Start node. The choices, their labels and + which of them watch an object are the Start node's own + trigger field (`fieldsForNodeType('start')`), so the two can + never offer different triggers; what is chosen is written + where that field writes it. */} + {newChoice === 'advanced' && startTrigger.trigger && ( +
+ + {startTrigger.trigger.help && ( +

{startTrigger.trigger.help}

+ )} + {startTrigger.object && newTrigger && startTrigger.object.showWhen?.equals.includes(newTrigger) && ( + + )}
)}
- ))} -
- - {/* objectui#11788 — the trigger, asked for here rather than found - later inside the Start node. The choices, their labels and - which of them watch an object are the Start node's own - trigger field (`fieldsForNodeType('start')`), so the two can - never offer different triggers; what is chosen is written - where that field writes it. */} - {newChoice === 'advanced' && startTrigger.trigger && ( -
- - {startTrigger.trigger.help && ( -

{startTrigger.trigger.help}

- )} - {startTrigger.object && newTrigger && startTrigger.object.showWhen?.equals.includes(newTrigger) && ( - - )} -
- )} -
-
+
+ } onSubmit={({ label, name }) => { if (newChoice === 'advanced') { @@ -8633,27 +8804,12 @@ export function AccessPillar({ setCreateTaken(null); try { // A name a permission set already holds, published or as a draft, in - // this package or any other, is refused here and nothing is written. - // The first-write pin below asserts "no DRAFT row" only (the spec's - // `parentVersion: null` is lifecycle-scoped), so over a published set - // it would write this blank skeleton as that set's pending draft, and - // publishing the package would replace the live set's grants with it. - const [published, drafts] = await Promise.all([ - client.list>('permission'), - client.listDrafts({ type: 'permission' }) as Promise>, - ]); - const publishedRow = (published ?? []).find((row) => row?.name === name); - const draftRow = (drafts ?? []).find((row) => row?.name === name); - if (publishedRow || draftRow) { - const takenLabel = navItemLabelText( - (publishedRow?.label as I18nLabel | undefined) ?? draftRow?.label ?? undefined, - locale, - ); - setCreateTaken({ - name, - label: takenLabel.trim() || name, - here: perms.some((p) => p.name === name), - }); + // this package or any other, is refused here and nothing is written: + // over a published set the first-write pin below would write this + // blank skeleton as its pending draft (`findTakenIdentifier`). + const taken = await findTakenIdentifier(client, 'permission', name, locale); + if (taken) { + setCreateTaken({ ...taken, here: perms.some((p) => p.name === name) }); return; } // Package door → create as a DRAFT stamped with this package (D6/D7), @@ -9064,25 +9220,16 @@ export function AccessPillar({ locale={locale} extra={ createTaken && ( -
-

- {tFormat( - createTaken.here ? 'engine.studio.access.createTaken' : 'engine.studio.access.createTakenElsewhere', - locale, - { label: createTaken.label, name: createTaken.name }, - )} -

- {createTaken.here && ( - + + openLabel={tFormat('engine.studio.create.openExisting', locale, { label: createTaken.label })} + onOpen={createTaken.here ? () => openTakenSet(createTaken.name) : undefined} + /> ) } onSubmit={({ label, name }) => void doCreate(label, name)} diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 343ce6d7c2c..d8415b8df7d 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -2191,7 +2191,6 @@ const STUDIO_EN: Record = { // Data pillar 'engine.studio.data.newFieldLabel': 'New field', 'engine.studio.data.nameFieldLabel': 'Name', - 'engine.studio.data.idExists': 'Identifier “{name}” already exists', 'engine.studio.data.fieldCount': '{count} fields', 'engine.studio.data.pickObject': 'Select an object', 'engine.studio.data.objects': 'Objects', @@ -2447,10 +2446,17 @@ const STUDIO_EN: Record = { // Access pillar: a create over a taken identifier, and the draft discard (objectstack#26836). 'engine.studio.access.createTaken': 'This package already has a permission set with the identifier “{name}”: {label}. Open it, or choose another identifier.', 'engine.studio.access.createTakenElsewhere': 'A permission set with the identifier “{name}” already exists outside this package: {label}. Choose another identifier.', - 'engine.studio.access.openExisting': 'Open “{label}”', 'engine.studio.access.discardConfirm': 'Discard the pending draft of “{item}”? The published set stays as it is; only its unpublished changes are removed.', 'engine.studio.access.discardConfirmUnpublished': 'Discard “{item}”? It has never been published, so discarding its draft removes the set.', 'engine.studio.access.discardFailed': 'Could not discard the draft', + // Studio creates over a taken identifier: the Automations, App and Data creates + // (objectstack#26917), and the "Open" every create's refusal offers. + 'engine.studio.create.openExisting': 'Open “{label}”', + 'engine.studio.auto.createTaken': 'This package already has an automation with the identifier “{name}”: {label}. Open it, or choose another identifier.', + 'engine.studio.auto.createTakenElsewhere': 'An automation with the identifier “{name}” already exists outside this package: {label}. Choose another identifier.', + 'engine.studio.app.createTaken': 'An app with the identifier “{name}” already exists: {label}. Choose another identifier.', + 'engine.studio.data.createTaken': 'This package already has an object with the identifier “{name}”: {label}. Open it, or choose another identifier.', + 'engine.studio.data.createTakenElsewhere': 'An object with the identifier “{name}” already exists outside this package: {label}. Choose another identifier.', // The rest of engine.studio.* (all but engine.studio.inspector) and the // metadata preview panes (engine.*Preview, engine.previewShell; the doc, book, // flow, page-block and translation previews excepted) (#25922 slice S5). diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 0ec52f97263..d75938d2f49 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -1976,7 +1976,6 @@ const STUDIO_ZH: Record = { // Data pillar 'engine.studio.data.newFieldLabel': '新字段', 'engine.studio.data.nameFieldLabel': '名称', - 'engine.studio.data.idExists': '标识符 "{name}" 已存在', 'engine.studio.data.fieldCount': '{count} 字段', 'engine.studio.data.pickObject': '选择一个对象', 'engine.studio.data.objects': '对象', @@ -2209,10 +2208,17 @@ const STUDIO_ZH: Record = { // Access pillar: a create over a taken identifier, and the draft discard (objectstack#26836). 'engine.studio.access.createTaken': '此软件包已有标识为「{name}」的权限集:{label}。请打开它,或换一个标识。', 'engine.studio.access.createTakenElsewhere': '此软件包之外已有标识为「{name}」的权限集:{label}。请换一个标识。', - 'engine.studio.access.openExisting': '打开「{label}」', 'engine.studio.access.discardConfirm': '放弃「{item}」的待发布草稿吗?已发布的权限集保持不变,只移除未发布的修改。', 'engine.studio.access.discardConfirmUnpublished': '放弃「{item}」吗?它从未发布过,放弃草稿会移除这个权限集。', 'engine.studio.access.discardFailed': '无法放弃草稿', + // Studio creates over a taken identifier: the Automations, App and Data creates + // (objectstack#26917), and the "Open" every create's refusal offers. + 'engine.studio.create.openExisting': '打开「{label}」', + 'engine.studio.auto.createTaken': '此软件包已有标识为「{name}」的自动化:{label}。请打开它,或换一个标识。', + 'engine.studio.auto.createTakenElsewhere': '此软件包之外已有标识为「{name}」的自动化:{label}。请换一个标识。', + 'engine.studio.app.createTaken': '已有标识为「{name}」的应用:{label}。请换一个标识。', + 'engine.studio.data.createTaken': '此软件包已有标识为「{name}」的对象:{label}。请打开它,或换一个标识。', + 'engine.studio.data.createTakenElsewhere': '此软件包之外已有标识为「{name}」的对象:{label}。请换一个标识。', // The rest of engine.studio.* (all but engine.studio.inspector) and the // metadata preview panes (engine.*Preview, engine.previewShell; the doc, book, // flow, page-block and translation previews excepted) (#25922 slice S5).