diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.heldDataCreate-26845.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.heldDataCreate-26845.test.tsx new file mode 100644 index 00000000000..13f1e08d0b3 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.heldDataCreate-26845.test.tsx @@ -0,0 +1,237 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26845 — the two departures objectstack#26705 left open in the + * Data pillar: creating an object, and opening another object from the + * Pending changes sheet. Both load another object into the one buffer a held + * edit lives in, so both ask the shared departure question first. + * + * New object asks before its dialog opens, so Cancel costs nothing and the + * create itself asks nothing. A Pending changes request for another object + * asks before the pillar applies it; one for the open object drops nothing + * and asks nothing. + * + * Rendered through the REAL `StudioDesignSurface` on the real route shape, as + * in `StudioDesignSurface.heldDataLeave-26705.test.tsx`; the sheet is a stand-in + * that asks the host for an object exactly as its security block does + * (`useSurfaceNavigator`). + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +const PKG = 'com.acme.app'; + +const OBJECTS: Record> = { + acme_task: { name: 'acme_task', label: 'Task', fields: [{ name: 'title', label: 'Title', type: 'text' }] }, + acme_note: { name: 'acme_note', label: 'Note', fields: [{ name: 'body', label: 'Body', type: 'text' }] }, +}; + +const mockClient = vi.hoisted(() => ({ + list: vi.fn(async (_type: string) => [] as unknown[]), + listDrafts: vi.fn(async () => []), + listTypes: vi.fn(async () => ({ entries: [] })), + get: vi.fn(async () => null), + references: vi.fn(async () => []), + layered: vi.fn(async (_type: string, _name: string) => ({ code: null, overlay: null, overlayScope: null, effective: null as unknown })), + getDraft: vi.fn(async () => null), + save: vi.fn(async () => ({ success: true })), + publishDraft: vi.fn(async () => ({ success: true })), + publishPackageDrafts: vi.fn(async () => ({ success: true, failed: [] })), + publish: vi.fn(async () => ({ success: true })), + reset: vi.fn(async () => ({})), +})); + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) }; +}); + +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + fetchPackages: vi.fn(async () => [{ id: 'com.acme.app', name: 'Acme', writable: true, namespace: 'acme' }]), + }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => dataSource }; +}); + +vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null })); +// Stands in for the Pending changes sheet: its security block asks the host to +// open the object a draft names, through the surface channel. +vi.mock('../../preview/DraftChangesPanel', async () => { + const { createElement } = await import('react'); + const { useSurfaceNavigator } = await import('./surfaceDeepLinkChannel'); + return { + DraftChangesPanel: () => { + const openSurface = useSurfaceNavigator(); + return createElement( + 'div', + null, + ['acme_task', 'acme_note'].map((name) => + createElement( + 'button', + { key: name, type: 'button', onClick: () => openSurface?.({ type: 'object', name }) }, + `Pending object/${name}`, + ), + ), + ); + }, + }; +}); + +import { StudioDesignSurface } from './StudioDesignSurface'; +import { addFieldFromFormToolbar } from './__tests__/dataPillarAddField'; +import { createEmptyDataSource } from './__tests__/emptyDataSource'; +import { registerBuiltinInspectors } from '../metadata-admin/inspectors'; +import { t } from '../metadata-admin/i18n'; + +const dataSource = createEmptyDataSource(); +registerBuiltinInspectors(); + +vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })), +); + +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() {} + }; + +// This jsdom environment ships no window.confirm — install a mock outright. +let confirmSpy: ReturnType; +const LEAVE_QUESTION = t('engine.edit.unsavedLeaveConfirm', 'en'); + +beforeEach(() => { + for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear(); + mockClient.list.mockImplementation(async (type: string) => + type === 'object' ? Object.values(OBJECTS).map((o) => ({ name: o.name, label: o.label, _packageId: PKG })) : [], + ); + mockClient.layered.mockImplementation(async (type: string, name: string) => ({ + code: null, + overlay: null, + overlayScope: null, + effective: type === 'object' && OBJECTS[name] ? JSON.parse(JSON.stringify(OBJECTS[name])) : null, + })); + confirmSpy = vi.fn(); + window.confirm = confirmSpy as unknown as typeof window.confirm; +}); + +afterEach(() => { + cleanup(); + localStorage.clear(); + window.history.replaceState(null, '', '/'); +}); + +/** The object the header names: its api-name chip. */ +async function openObjectIs(name: string): Promise { + await waitFor( + () => expect(within(screen.getByTestId('data-object-header')).getByTestId('studio-item-api-name')).toHaveTextContent(name), + { timeout: 8000 }, + ); +} + +async function openTask(): Promise { + // The shell's link guard reads the window's URL to skip a link to the page + // already shown; `BrowserRouter` keeps the two equal, so this does too. + window.history.replaceState(null, '', `/studio/${PKG}/data`); + render( + + + } /> + + , + ); + await openObjectIs('acme_task'); +} + +/** The card's repro: add a Picklist "Priority" with no option yet; the autosave holds it. */ +async function holdAPicklist(): Promise { + fireEvent.click(await screen.findByRole('button', { name: 'Form' })); + await addFieldFromFormToolbar(undefined, { label: 'Priority', type: /^Picklist$/ }); + await screen.findByTestId('studio-held', undefined, { timeout: 8000 }); +} + +const newObject = () => fireEvent.click(within(screen.getByTestId('studio-rail-data')).getByTestId('studio-rail-add')); +const pendingRequest = (name: string) => fireEvent.click(screen.getByRole('button', { name: `Pending object/${name}` })); + +describe('a held Data field guards create and Pending changes (objectstack#26845)', () => { + it('New object asks; Cancel opens no dialog and keeps the held field, OK opens it and the create asks nothing', async () => { + await openTask(); + await holdAPicklist(); + + confirmSpy.mockReturnValueOnce(false); + newObject(); + expect(confirmSpy).toHaveBeenCalledTimes(1); + expect(confirmSpy).toHaveBeenCalledWith(LEAVE_QUESTION); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(screen.getByTestId('studio-held')).toBeInTheDocument(); + + confirmSpy.mockReturnValueOnce(true); + newObject(); + expect(confirmSpy).toHaveBeenCalledTimes(2); + const dialog = await screen.findByRole('dialog'); + fireEvent.change(within(dialog).getByLabelText(t('engine.studio.data.nameLabel', 'en')), { + target: { value: 'Ticket' }, + }); + fireEvent.click(within(dialog).getByRole('button', { name: t('engine.studio.createDraft', 'en') })); + await openObjectIs('acme_ticket'); + expect(confirmSpy).toHaveBeenCalledTimes(2); + }); + + it('a Pending changes request for another object asks; Cancel keeps the held field, OK opens the object', async () => { + await openTask(); + await holdAPicklist(); + + confirmSpy.mockReturnValueOnce(false); + pendingRequest('acme_note'); + await waitFor(() => expect(confirmSpy).toHaveBeenCalledTimes(1)); + expect(confirmSpy).toHaveBeenCalledWith(LEAVE_QUESTION); + await openObjectIs('acme_task'); + expect(screen.getByTestId('studio-held')).toBeInTheDocument(); + + // A request for the open object drops nothing, so it asks nothing. + pendingRequest('acme_task'); + await openObjectIs('acme_task'); + expect(screen.getByTestId('studio-held')).toBeInTheDocument(); + expect(confirmSpy).toHaveBeenCalledTimes(1); + + confirmSpy.mockReturnValueOnce(true); + pendingRequest('acme_note'); + await waitFor(() => expect(confirmSpy).toHaveBeenCalledTimes(2)); + await openObjectIs('acme_note'); + expect(screen.queryByTestId('studio-held')).toBeNull(); + }); + + // CONTROL: nothing held, nothing asked. + it('with no edit held, New object and a Pending changes request never ask', async () => { + await openTask(); + + pendingRequest('acme_note'); + await openObjectIs('acme_note'); + newObject(); + await screen.findByRole('dialog'); + expect(confirmSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.heldFlowCreate-26845.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.heldFlowCreate-26845.test.tsx new file mode 100644 index 00000000000..d87584cbcb7 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.heldFlowCreate-26845.test.tsx @@ -0,0 +1,255 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26845 — the two departures objectstack#26705 left open, seen + * from the Automations pillar: creating a flow, and a Pending changes request + * while a step is held. + * + * New flow opens the created flow, whose load replaces the buffer the held + * step lives in, so it asks before its dialog opens. A Pending changes request + * names an object, which this pillar does not hold: it leaves the pillar + * through the surface's leave guard, which asks once, and the Data pillar it + * lands on holds nothing, so nothing asks a second time. + * + * Rendered through the REAL `StudioDesignSurface` on the real route shape, as + * in `StudioDesignSurface.heldFlowLeave-26705.test.tsx`; the sheet is a stand-in + * that asks the host for an object exactly as its security block does + * (`useSurfaceNavigator`). + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +const PKG = 'com.acme.app'; + +const flowNamed = (name: string, label: string) => ({ + name, + label, + type: 'autolaunched', + status: 'obsolete', + _packageId: PKG, + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], +}); + +const NOTE = { name: 'acme_note', label: 'Note', fields: [{ name: 'body', label: 'Body', type: 'text' }] }; + +const FLOWS: Record> = { + approval: flowNamed('approval', 'Approval'), + reminder: flowNamed('reminder', 'Reminder'), +}; + +const mockClient = vi.hoisted(() => ({ + list: vi.fn(async (_type: string) => [] as unknown[]), + listDrafts: vi.fn(async () => []), + listTypes: vi.fn(async () => ({ entries: [] })), + get: vi.fn(async () => null), + references: vi.fn(async () => []), + layered: vi.fn(async (_type: string, _name: string) => ({ code: null, overlay: null, overlayScope: null, effective: null as unknown })), + getDraft: vi.fn(async (type: string, name: string) => { + throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 }); + }), + save: vi.fn(async () => ({})), + publishDraft: vi.fn(async () => ({ success: true })), + publishPackageDrafts: vi.fn(async () => ({ success: true, failed: [] })), + publish: vi.fn(async () => ({ success: true })), + reset: vi.fn(async () => ({})), +})); + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) }; +}); + +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + fetchPackages: vi.fn(async () => [{ id: 'com.acme.app', name: 'Acme', writable: true, namespace: 'acme' }]), + }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => dataSource }; +}); + +vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null })); +// Stands in for the Pending changes sheet: its security block asks the host to +// open the object a draft names, through the surface channel. +vi.mock('../../preview/DraftChangesPanel', async () => { + const { createElement } = await import('react'); + const { useSurfaceNavigator } = await import('./surfaceDeepLinkChannel'); + return { + DraftChangesPanel: () => { + const openSurface = useSurfaceNavigator(); + return createElement( + 'button', + { type: 'button', onClick: () => openSurface?.({ type: 'object', name: 'acme_note' }) }, + 'Pending object/acme_note', + ); + }, + }; +}); + +import { StudioDesignSurface } from './StudioDesignSurface'; +import { createEmptyDataSource } from './__tests__/emptyDataSource'; +import { registerMetadataPreview } from '../metadata-admin/preview-registry'; +import { registerMetadataInspector } from '../metadata-admin/inspector-registry'; +import { FlowPreview } from '../metadata-admin/previews/FlowPreview'; +import { FlowInspector } from '../metadata-admin/inspectors/FlowInspector'; +import { browserClick } from '../metadata-admin/previews/__tests__/browserClick'; +import { t } from '../metadata-admin/i18n'; + +const dataSource = createEmptyDataSource(); + +vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })), +); + +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() {} + }; + +registerMetadataPreview('flow', FlowPreview); +registerMetadataInspector('flow', FlowInspector); + +// This jsdom environment ships no window.confirm — install a mock outright. +let confirmSpy: ReturnType; +const LEAVE_QUESTION = t('engine.edit.unsavedLeaveConfirm', 'en'); + +beforeEach(() => { + for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear(); + mockClient.list.mockImplementation(async (type: string) => + type === 'flow' + ? Object.values(FLOWS).map((f) => ({ name: f.name, label: f.label, _packageId: PKG })) + : type === 'object' + ? [{ name: NOTE.name, label: NOTE.label, _packageId: PKG }] + : [], + ); + mockClient.layered.mockImplementation(async (type: string, name: string) => ({ + code: null, + overlay: null, + overlayScope: null, + effective: + type === 'flow' && FLOWS[name] + ? JSON.parse(JSON.stringify(FLOWS[name])) + : type === 'object' && name === NOTE.name + ? JSON.parse(JSON.stringify(NOTE)) + : null, + })); + confirmSpy = vi.fn(); + window.confirm = confirmSpy as unknown as typeof window.confirm; +}); + +afterEach(() => { + cleanup(); + localStorage.clear(); + window.history.replaceState(null, '', '/'); +}); + +async function openFlow(): Promise { + // The shell's link guard reads the window's URL to skip a link to the page + // already shown; `BrowserRouter` keeps the two equal, so this does too. + window.history.replaceState(null, '', `/studio/${PKG}/automations`); + render( + + + } /> + + , + ); + await screen.findByTitle('API name: approval', undefined, { timeout: 8000 }); +} + +/** Add a Notify step and leave its Title empty: the autosave holds it. */ +async function holdANotifyStep(): Promise { + fireEvent.click(await screen.findByRole('button', { name: 'Insert node here' }, { timeout: 8000 })); + const option = screen.getAllByRole('option').find((o) => o.textContent?.startsWith('Notify')); + browserClick(option!); + await screen.findByTestId('studio-held', undefined, { timeout: 8000 }); +} + +const newFlow = () => fireEvent.click(within(screen.getByTestId('studio-rail-automations')).getByTestId('studio-rail-add')); +const pendingRequest = () => fireEvent.click(screen.getByRole('button', { name: 'Pending object/acme_note' })); + +/** The Data pillar is open on the Pending changes request's object. */ +async function noteIsOpen(): Promise { + await screen.findByTestId('studio-rail-data', undefined, { timeout: 8000 }); + await waitFor( + () => expect(within(screen.getByTestId('data-object-header')).getByTestId('studio-item-api-name')).toHaveTextContent('acme_note'), + { timeout: 8000 }, + ); +} + +describe('a held flow step guards create and Pending changes (objectstack#26845)', () => { + it('New flow asks; Cancel opens no dialog and keeps the held step, OK opens the dialog', async () => { + await openFlow(); + await holdANotifyStep(); + + confirmSpy.mockReturnValueOnce(false); + newFlow(); + expect(confirmSpy).toHaveBeenCalledTimes(1); + expect(confirmSpy).toHaveBeenCalledWith(LEAVE_QUESTION); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(screen.getByTestId('studio-held')).toBeInTheDocument(); + + confirmSpy.mockReturnValueOnce(true); + newFlow(); + expect(confirmSpy).toHaveBeenCalledTimes(2); + await screen.findByRole('dialog'); + }); + + it("a Pending changes request for another pillar's item asks once; Cancel keeps the held step, OK opens it", async () => { + await openFlow(); + await holdANotifyStep(); + + confirmSpy.mockReturnValueOnce(false); + pendingRequest(); + expect(confirmSpy).toHaveBeenCalledTimes(1); + expect(confirmSpy).toHaveBeenCalledWith(LEAVE_QUESTION); + expect(screen.getByTestId('studio-rail-automations')).toBeInTheDocument(); + expect(screen.getByTestId('studio-held')).toBeInTheDocument(); + + confirmSpy.mockReturnValueOnce(true); + pendingRequest(); + await noteIsOpen(); + // The surface's leave guard asked; the Data pillar holds nothing, so the + // request it applies on arrival asks nothing more. + expect(confirmSpy).toHaveBeenCalledTimes(2); + }); + + // CONTROL: nothing held, nothing asked. + it('with no edit held, New flow and a Pending changes request never ask', async () => { + await openFlow(); + + newFlow(); + await screen.findByRole('dialog'); + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + pendingRequest(); + await noteIsOpen(); + expect(confirmSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.heldWidgetCreate-26845.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.heldWidgetCreate-26845.test.tsx new file mode 100644 index 00000000000..03745e47fa1 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.heldWidgetCreate-26845.test.tsx @@ -0,0 +1,211 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26845 — the create departure objectstack#26705 left open in the + * Interfaces pillar. A new dashboard, report or page opens at once, and its + * load replaces the leaf buffer a held widget lives in, so the "+" menu's + * create asks the shared departure question before its dialog opens: Cancel + * costs nothing, and the create itself asks nothing. + * + * Rendered through the REAL `StudioDesignSurface` on the real route shape, as + * in `StudioDesignSurface.heldWidgetLeave-26705.test.tsx`. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +// Module-scope import of the lazily loaded canvas renderer (AGENTS.md, +// flaky-test discipline). +import '@object-ui/plugin-dashboard'; + +const PKG = 'com.acme.app'; + +const NAV = [ + { id: 'nav_sales', type: 'dashboard', label: 'Sales', dashboardName: 'sales_dash' }, + { id: 'nav_ops', type: 'dashboard', label: 'Ops', dashboardName: 'ops_dash' }, +]; + +const DASHBOARDS: Record> = { + sales_dash: { + name: 'sales_dash', + label: 'Sales', + widgets: [{ id: 'pipeline', type: 'metric', title: 'Pipeline', dataset: 'sales_ds', values: ['revenue'] }], + }, + ops_dash: { + name: 'ops_dash', + label: 'Ops', + widgets: [{ id: 'backlog', type: 'metric', title: 'Backlog', dataset: 'sales_ds', values: ['revenue'] }], + }, +}; + +const DATASETS = [ + { + name: 'sales_ds', + label: 'Sales', + object: 'opportunity', + dimensions: [{ name: 'stage', label: 'Stage' }], + measures: [{ name: 'revenue', label: 'Revenue', aggregate: 'sum' }], + }, +]; + +const mockClient = vi.hoisted(() => ({ + list: vi.fn(async (_type: string) => [] as unknown[]), + listDrafts: vi.fn(async () => []), + listTypes: vi.fn(async () => ({ entries: [] })), + get: vi.fn(async (_type: string, _name: string) => null as unknown), + references: vi.fn(async () => []), + layered: vi.fn(async (_type: string, _name: string) => ({ code: null, overlay: null, overlayScope: null, effective: null as unknown })), + getDraft: vi.fn(async () => null), + save: vi.fn(async () => ({ success: true, version: 'v1', state: 'draft' })), + publishDraft: vi.fn(async () => ({ success: true })), + publishPackageDrafts: vi.fn(async () => ({ success: true, failed: [] })), + publish: vi.fn(async () => ({ success: true })), + reset: vi.fn(async () => ({})), +})); + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) }; +}); + +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + fetchPackages: vi.fn(async () => [{ id: 'com.acme.app', name: 'Acme', writable: true, namespace: 'acme' }]), + }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => ({}) }; +}); + +vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null })); +vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => null })); + +import { StudioDesignSurface } from './StudioDesignSurface'; +import { registerMetadataPreview } from '../metadata-admin/preview-registry'; +import { registerMetadataInspector } from '../metadata-admin/inspector-registry'; +import { DashboardPreview } from '../metadata-admin/previews/DashboardPreview'; +import { DashboardWidgetInspector } from '../metadata-admin/inspectors/DashboardWidgetInspector'; +import { t } from '../metadata-admin/i18n'; + +vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })), +); + +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() {} + }; + +registerMetadataPreview('dashboard', DashboardPreview); +registerMetadataInspector('dashboard', DashboardWidgetInspector); + +// This jsdom environment ships no window.confirm — install a mock outright. +let confirmSpy: ReturnType; +const LEAVE_QUESTION = t('engine.edit.unsavedLeaveConfirm', 'en'); + +beforeEach(() => { + for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear(); + mockClient.list.mockImplementation(async (type: string) => { + if (type === 'app') return [{ name: 'acme_app', label: 'Acme' }]; + if (type === 'dashboard') return Object.values(DASHBOARDS); + if (type === 'dataset') return DATASETS; + return []; + }); + mockClient.layered.mockImplementation(async (type: string, name: string) => { + const effective = + type === 'app' + ? { name: 'acme_app', label: 'Acme', navigation: NAV } + : type === 'dashboard' && DASHBOARDS[name] + ? JSON.parse(JSON.stringify(DASHBOARDS[name])) + : null; + return { code: null, overlay: null, overlayScope: null, effective }; + }); + mockClient.get.mockImplementation(async (type: string, name: string) => + type === 'dataset' ? DATASETS.find((d) => d.name === name) : null, + ); + confirmSpy = vi.fn(); + window.confirm = confirmSpy as unknown as typeof window.confirm; +}); + +afterEach(() => { + cleanup(); + localStorage.clear(); + window.history.replaceState(null, '', '/'); +}); + +async function openSalesDashboard(): Promise { + // The shell's link guard reads the window's URL to skip a link to the page + // already shown; `BrowserRouter` keeps the two equal, so this does too. + window.history.replaceState(null, '', `/studio/${PKG}/interfaces`); + render( + + + } /> + + , + ); + await screen.findByTitle('dashboard · sales_dash', undefined, { timeout: 8000 }); + await screen.findByRole('button', { name: t('engine.inspector.add.widget', 'en') }, { timeout: 8000 }); +} + +/** Add a Metric widget and bind nothing: the leaf autosave holds it. */ +async function holdAMetric(): Promise { + await userEvent.click(screen.getByRole('button', { name: t('engine.inspector.add.widget', 'en') })); + await userEvent.click(await screen.findByRole('button', { name: /^Metric \(KPI\)/ })); + await screen.findByTestId('studio-held', undefined, { timeout: 8000 }); +} + +/** The rail's "+" menu, then its New dashboard item. */ +async function newDashboard(): Promise { + await userEvent.click(screen.getByTestId('if-create-menu')); + await userEvent.click(await screen.findByTestId('if-create-dashboard')); +} + +describe('a held dashboard widget guards create (objectstack#26845)', () => { + it('New dashboard asks; Cancel opens no dialog and keeps the held widget, OK opens the dialog', async () => { + await openSalesDashboard(); + await holdAMetric(); + + confirmSpy.mockReturnValueOnce(false); + await newDashboard(); + expect(confirmSpy).toHaveBeenCalledTimes(1); + expect(confirmSpy).toHaveBeenCalledWith(LEAVE_QUESTION); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(screen.getByTestId('studio-held')).toBeInTheDocument(); + + confirmSpy.mockReturnValueOnce(true); + await newDashboard(); + expect(confirmSpy).toHaveBeenCalledTimes(2); + await screen.findByRole('dialog'); + }); + + // CONTROL: nothing held, nothing asked. + it('with no edit held, New dashboard never asks', async () => { + await openSalesDashboard(); + + await newDashboard(); + await screen.findByRole('dialog'); + expect(confirmSpy).not.toHaveBeenCalled(); + }); +}); 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 eb0b0dda85f..bd7f5c9aed8 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -4161,12 +4161,19 @@ export function InterfacesPillar({ const [createError, setCreateError] = React.useState(null); const [reportBinding, setReportBinding] = React.useState(NO_REPORT_BINDING); const [pageKind, setPageKind] = React.useState(DEFAULT_PAGE_SOURCE_KIND); - const openCreate = React.useCallback((kind: InterfaceCreateKind) => { - setCreateError(null); - setReportBinding(NO_REPORT_BINDING); - setPageKind(DEFAULT_PAGE_SOURCE_KIND); - setCreateKind(kind); - }, []); + const openCreate = React.useCallback( + (kind: InterfaceCreateKind) => { + // objectstack#26845 — the created item opens at once, and its load + // replaces the buffer a held widget lives in: asked before the dialog + // opens, so Cancel costs nothing and the create itself asks nothing. + if (!confirmDropUnsavedWork(leafHeld !== null, locale)) return; + setCreateError(null); + setReportBinding(NO_REPORT_BINDING); + setPageKind(DEFAULT_PAGE_SOURCE_KIND); + setCreateKind(kind); + }, + [leafHeld, locale], + ); // objectui#12455 — the "+" kind picker's other kinds: the entry is added // where "Add nav item" adds one (the end of the top level, an id minted the // way `AppNavCanvas` mints it), born with no label (objectui#11196) and, for @@ -5500,15 +5507,10 @@ export function DataPillar({ // they had since selected, which is the regression the mount-time ref exists // to prevent — so the id is marked spent as soon as the loaded rail has been // consulted, whether or not it held a match. + // + // Applied below `objHeld`, which it asks about (objectstack#26845). const requestedSurface = useRequestedSurface(); const appliedRequestRef = React.useRef(0); - React.useEffect(() => { - if (!requestedSurface || !objectsLoaded) return; - if (requestedSurface.id === appliedRequestRef.current) return; - appliedRequestRef.current = requestedSurface.id; - const match = resolveSurfaceDeepLink(objects, requestedSurface.target, 'object'); - if (match) setCurrent(match); - }, [requestedSurface, objects, objectsLoaded]); const [objDraft, setObjDraft] = React.useState>({}); // objectui#11272 — the object `objDraft` was loaded for, `object:NAME`: // written where the load below installs it, and nowhere else. @@ -6062,6 +6064,26 @@ export function DataPillar({ const fieldCopilotMark = objLoaded && fieldSel?.kind === 'field' ? copilotDraftMarkOf(objDraftChanges, ['fields', fieldSel.id]) : null; useHeldEditReport(onHeldEditChange, objHeld?.clause ?? null); + // The live request's apply (see `requestedSurface` above). objectstack#26845 + // — another object's load replaces the buffer a held edit lives in, so it + // asks first, as the rail does; declined, the request is spent and the held + // edit stays. A request for the open object drops nothing and asks nothing. + React.useEffect(() => { + if (!requestedSurface || !objectsLoaded) return; + if (requestedSurface.id === appliedRequestRef.current) return; + appliedRequestRef.current = requestedSurface.id; + const match = resolveSurfaceDeepLink(objects, requestedSurface.target, 'object'); + if (!match) return; + if (match.name !== current?.name && !confirmDropUnsavedWork(objHeld !== null, locale)) return; + setCurrent(match); + }, [requestedSurface, objects, objectsLoaded, current, objHeld, locale]); + // objectstack#26845 — a created object opens at once, and its load replaces + // the buffer a held edit lives in: asked before the dialog opens, so Cancel + // costs nothing and the create itself asks nothing. + const openCreateObject = () => { + if (!confirmDropUnsavedWork(objHeld !== null, locale)) return; + openCreateDialog(); + }; // objectui#12725 — the header's draft line reads the badge's ledger. const objDraftStatus = useStudioItemDraftStatus( current ? { type: 'object', name: current.name } : null, @@ -6310,7 +6332,7 @@ export function DataPillar({ testId="studio-rail-data" heading={t('engine.studio.data.objects', locale)} count={objects.length} - onAdd={readOnly ? undefined : openCreateDialog} + onAdd={readOnly ? undefined : openCreateObject} addLabel={t('engine.studio.data.newObject', locale)} addTestId="studio-rail-add" search={{ value: query, onChange: setQuery, placeholder: t('engine.studio.data.searchObjects', locale) }} @@ -6435,7 +6457,7 @@ export function DataPillar({