diff --git a/ui/packages/app-shell/src/views/metadata-admin/PackageFormDialog.validationTiming.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/PackageFormDialog.validationTiming.test.tsx index d39b68984a7..3095e359a0a 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PackageFormDialog.validationTiming.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PackageFormDialog.validationTiming.test.tsx @@ -20,6 +20,11 @@ import * as React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { PackageFormDialog } from './PackageFormDialog'; +// The package form rows this suite reads in zh live in `@object-ui/i18n`'s `studio` +// namespace, whose `zh` catalogue is fetched on demand (objectstack#25922). +// Resident before any case, so the zh rows below read synchronously. +import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio'; +await loadStudioCatalogue('zh'); let localeFixture: 'en-US' | 'zh-CN' = 'zh-CN'; vi.mock('./i18n', async (importOriginal) => ({ diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.discardDraftLabel-12690.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.discardDraftLabel-12690.test.tsx index 0b73f9f4ddb..6e297b1d55f 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.discardDraftLabel-12690.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.discardDraftLabel-12690.test.tsx @@ -103,6 +103,11 @@ import { MetadataResourceEditPage } from './ResourceEditPage'; import { registerDefaultMetadataSchemas } from './default-schemas'; import { registerBuiltinPreviews } from './previews'; import { tFormat } from './i18n'; +// The editor rows this suite reads in zh live in `@object-ui/i18n`'s `studio` +// namespace, whose `zh` catalogue is fetched on demand (objectstack#25922). +// Resident before any case, so the zh rows below read synchronously. +import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio'; +await loadStudioCatalogue('zh'); const DOC_JSON_SCHEMA = z.toJSONSchema(DocSchema as unknown as z.ZodType) as Record; client.current = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch }); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.heldDataLeave-26705.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.heldDataLeave-26705.test.tsx new file mode 100644 index 00000000000..1e805fc89ca --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.heldDataLeave-26705.test.tsx @@ -0,0 +1,225 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26705 — an edit the Data pillar holds unsent is unsaved work: + * every departure that would drop it asks first. + * + * The autosave holds a Picklist added before it has an option + * (objectui#11786). Before this card only Publish read that hold; leaving the + * pillar, opening another object in the rail or reloading dropped the field + * with no question. Now the surface folds the held edit into the guard a dirty + * pillar already raises (the leave confirm, the shell's link guard and + * `beforeunload`), and the rail asks before it opens another object. + * + * Rendered through the REAL `StudioDesignSurface` on the real route shape; the + * field is added the way the pillar offers it (`dataPillarAddField`). + */ + +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 })); +vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => null })); + +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 }); +} + +/** Whether a reload / tab close would be asked about. */ +function reloadAsks(): boolean { + const e = new Event('beforeunload', { cancelable: true }); + window.dispatchEvent(e); + return e.defaultPrevented; +} + +const pillarLink = (name: string) => within(screen.getByTestId('studio-pillar-nav')).getByRole('link', { name }); +const railRow = (label: string) => within(screen.getByTestId('studio-rail-data')).getByRole('button', { name: label }); + +describe('a held Data field guards every departure (objectstack#26705)', () => { + it('leaving the pillar asks; Cancel keeps the held field, OK leaves and clears the guard', async () => { + await openTask(); + await holdAPicklist(); + + confirmSpy.mockReturnValueOnce(false); + fireEvent.click(pillarLink('Automations')); + expect(confirmSpy).toHaveBeenCalledTimes(1); + expect(confirmSpy).toHaveBeenCalledWith(LEAVE_QUESTION); + expect(screen.getByTestId('studio-held')).toBeInTheDocument(); + expect(screen.getByTestId('studio-rail-data')).toBeInTheDocument(); + + confirmSpy.mockReturnValueOnce(true); + fireEvent.click(pillarLink('Automations')); + expect(confirmSpy).toHaveBeenCalledTimes(2); + await screen.findByTestId('studio-rail-automations'); + // The unmounted pillar took its hold back: nothing is asked any more. + expect(reloadAsks()).toBe(false); + fireEvent.click(pillarLink('Data')); + expect(confirmSpy).toHaveBeenCalledTimes(2); + }); + + it('opening another object in the rail asks; Cancel keeps the held field, OK opens the other object', async () => { + await openTask(); + await holdAPicklist(); + + confirmSpy.mockReturnValueOnce(false); + fireEvent.click(railRow('Note')); + expect(confirmSpy).toHaveBeenCalledTimes(1); + expect(confirmSpy).toHaveBeenCalledWith(LEAVE_QUESTION); + await openObjectIs('acme_task'); + expect(screen.getByTestId('studio-held')).toBeInTheDocument(); + + // Re-picking the open object drops nothing, so it asks nothing. + fireEvent.click(railRow('Task')); + expect(confirmSpy).toHaveBeenCalledTimes(1); + + confirmSpy.mockReturnValueOnce(true); + fireEvent.click(railRow('Note')); + expect(confirmSpy).toHaveBeenCalledTimes(2); + await openObjectIs('acme_note'); + expect(screen.queryByTestId('studio-held')).toBeNull(); + }); + + it('a reload asks while the field is held', async () => { + await openTask(); + expect(reloadAsks()).toBe(false); + await holdAPicklist(); + expect(reloadAsks()).toBe(true); + }); + + // CONTROL: nothing held, nothing asked. + it('with no edit held, the rail, the reload and the pillar link never ask', async () => { + await openTask(); + + fireEvent.click(railRow('Note')); + await openObjectIs('acme_note'); + expect(reloadAsks()).toBe(false); + fireEvent.click(pillarLink('Automations')); + await screen.findByTestId('studio-rail-automations'); + expect(confirmSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.heldFlowLeave-26705.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.heldFlowLeave-26705.test.tsx new file mode 100644 index 00000000000..14dd683e310 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.heldFlowLeave-26705.test.tsx @@ -0,0 +1,238 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26705 — an edit the Automations pillar holds unsent is unsaved + * work: every departure that would drop it asks first. + * + * The autosave holds a step added before its required inputs are filled + * (objectui#11786). Before this card only Publish read that hold; leaving the + * pillar, opening another flow in the rail or reloading dropped the step with + * no question. Now the surface folds the held edit into the guard a dirty + * pillar already raises (the leave confirm, the shell's link guard and + * `beforeunload`), and the rail asks before it opens another flow. + * + * Rendered through the REAL `StudioDesignSurface` on the real route shape, with + * the real registered `FlowPreview` / `FlowInspector`, as in + * `StudioDesignSurface.heldPublish-11786.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, fireEvent, cleanup, 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 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 })); +vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => null })); + +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 })) : [], + ); + 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])) : 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 }); +} + +/** Whether a reload / tab close would be asked about. */ +function reloadAsks(): boolean { + const e = new Event('beforeunload', { cancelable: true }); + window.dispatchEvent(e); + return e.defaultPrevented; +} + +const pillarLink = (name: string) => within(screen.getByTestId('studio-pillar-nav')).getByRole('link', { name }); + +describe('a held flow step guards every departure (objectstack#26705)', () => { + it('leaving the pillar asks; Cancel keeps the held step, OK leaves and clears the guard', async () => { + await openFlow(); + await holdANotifyStep(); + + confirmSpy.mockReturnValueOnce(false); + fireEvent.click(pillarLink('Data')); + expect(confirmSpy).toHaveBeenCalledTimes(1); + expect(confirmSpy).toHaveBeenCalledWith(LEAVE_QUESTION); + expect(screen.getByTestId('studio-held')).toBeInTheDocument(); + expect(screen.getByTestId('studio-rail-automations')).toBeInTheDocument(); + + confirmSpy.mockReturnValueOnce(true); + fireEvent.click(pillarLink('Data')); + expect(confirmSpy).toHaveBeenCalledTimes(2); + await screen.findByTestId('studio-rail-data'); + // The unmounted pillar took its hold back: nothing is asked any more. + expect(reloadAsks()).toBe(false); + fireEvent.click(pillarLink('Automations')); + expect(confirmSpy).toHaveBeenCalledTimes(2); + }); + + it('opening another flow in the rail asks; Cancel keeps the held step, OK opens the other flow', async () => { + await openFlow(); + await holdANotifyStep(); + + confirmSpy.mockReturnValueOnce(false); + fireEvent.click(screen.getByTestId('auto-rail-row-reminder')); + expect(confirmSpy).toHaveBeenCalledTimes(1); + expect(confirmSpy).toHaveBeenCalledWith(LEAVE_QUESTION); + expect(screen.getByTitle('API name: approval')).toBeInTheDocument(); + expect(screen.getByTestId('studio-held')).toBeInTheDocument(); + + // Re-picking the open flow drops nothing, so it asks nothing. + fireEvent.click(screen.getByTestId('auto-rail-row-approval')); + expect(confirmSpy).toHaveBeenCalledTimes(1); + + confirmSpy.mockReturnValueOnce(true); + fireEvent.click(screen.getByTestId('auto-rail-row-reminder')); + expect(confirmSpy).toHaveBeenCalledTimes(2); + await screen.findByTitle('API name: reminder', undefined, { timeout: 8000 }); + expect(screen.queryByTestId('studio-held')).toBeNull(); + }); + + it('a reload asks while the step is held', async () => { + await openFlow(); + expect(reloadAsks()).toBe(false); + await holdANotifyStep(); + expect(reloadAsks()).toBe(true); + }); + + // CONTROL: nothing held, nothing asked. + it('with no edit held, the rail, the reload and the pillar link never ask', async () => { + await openFlow(); + + fireEvent.click(screen.getByTestId('auto-rail-row-reminder')); + await screen.findByTitle('API name: reminder', undefined, { timeout: 8000 }); + expect(reloadAsks()).toBe(false); + fireEvent.click(pillarLink('Data')); + await screen.findByTestId('studio-rail-data'); + expect(confirmSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.heldWidgetLeave-26705.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.heldWidgetLeave-26705.test.tsx new file mode 100644 index 00000000000..776e12e7fb9 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.heldWidgetLeave-26705.test.tsx @@ -0,0 +1,257 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26705 — a dashboard widget the Interfaces pillar holds unsent is + * unsaved work: every departure that would drop it asks first. + * + * The leaf autosave holds a widget added before it is bound to a dataset + * (objectui#11910). Before this card only Publish read that hold — the pillar's + * departure guard covered its navigation edits alone — so leaving the pillar, + * opening another nav entry's page or reloading dropped the widget with no + * question. Now the surface folds the held edit into the guard the navigation + * edits already raise (the leave confirm, the shell's link guard and + * `beforeunload`), and the rail asks before it opens another leaf. + * + * Rendered through the REAL `StudioDesignSurface` on the real route shape, with + * the real `DashboardPreview` / `DashboardWidgetInspector`, as in + * `StudioDesignSurface.heldWidgetPublish-11910.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, fireEvent, cleanup, within } 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 }); +} + +/** Whether a reload / tab close would be asked about. */ +function reloadAsks(): boolean { + const e = new Event('beforeunload', { cancelable: true }); + window.dispatchEvent(e); + return e.defaultPrevented; +} + +const pillarLink = (name: string) => within(screen.getByTestId('studio-pillar-nav')).getByRole('link', { name }); +const railRow = (dashboard: string) => screen.getByTitle(`dashboard · ${dashboard}`); + +describe('a held dashboard widget guards every departure (objectstack#26705)', () => { + it('leaving the pillar asks; Cancel keeps the held widget, OK leaves and clears the guard', async () => { + await openSalesDashboard(); + await holdAMetric(); + + confirmSpy.mockReturnValueOnce(false); + fireEvent.click(pillarLink('Data')); + expect(confirmSpy).toHaveBeenCalledTimes(1); + expect(confirmSpy).toHaveBeenCalledWith(LEAVE_QUESTION); + expect(screen.getByTestId('studio-held')).toBeInTheDocument(); + expect(screen.getByTestId('studio-rail-interfaces')).toBeInTheDocument(); + + confirmSpy.mockReturnValueOnce(true); + fireEvent.click(pillarLink('Data')); + expect(confirmSpy).toHaveBeenCalledTimes(2); + await screen.findByTestId('studio-rail-data'); + // The unmounted pillar took its hold back: nothing is asked any more. + expect(reloadAsks()).toBe(false); + fireEvent.click(pillarLink('Interfaces')); + expect(confirmSpy).toHaveBeenCalledTimes(2); + }); + + it("opening another entry's dashboard in the rail asks; Cancel keeps the held widget, OK opens it", async () => { + await openSalesDashboard(); + await holdAMetric(); + + confirmSpy.mockReturnValueOnce(false); + fireEvent.click(railRow('ops_dash')); + expect(confirmSpy).toHaveBeenCalledTimes(1); + expect(confirmSpy).toHaveBeenCalledWith(LEAVE_QUESTION); + expect(screen.getByTestId('studio-held')).toBeInTheDocument(); + + // Re-picking the open leaf drops nothing, so it asks nothing. + fireEvent.click(railRow('sales_dash')); + expect(confirmSpy).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('studio-held')).toBeInTheDocument(); + + confirmSpy.mockReturnValueOnce(true); + fireEvent.click(railRow('ops_dash')); + expect(confirmSpy).toHaveBeenCalledTimes(2); + await screen.findByText('Backlog', undefined, { timeout: 8000 }); + expect(screen.queryByTestId('studio-held')).toBeNull(); + }); + + it('a reload asks while the widget is held', async () => { + await openSalesDashboard(); + expect(reloadAsks()).toBe(false); + await holdAMetric(); + expect(reloadAsks()).toBe(true); + }); + + // CONTROL: nothing held, nothing asked. + it('with no edit held, the rail, the reload and the pillar link never ask', async () => { + await openSalesDashboard(); + + fireEvent.click(railRow('ops_dash')); + await screen.findByText('Backlog', undefined, { timeout: 8000 }); + expect(reloadAsks()).toBe(false); + fireEvent.click(pillarLink('Data')); + await screen.findByTestId('studio-rail-data'); + 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 8df83030071..a95d6953395 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -422,7 +422,8 @@ function interfacesSaveBody(type: string, draft: Record): Recor /** * objectui#11786 — tells the surface's Publish what edit a pillar holds unsent * (`StudioHeld.clause`, or `null`), and takes it back when the pillar unmounts, - * so a pillar left behind never keeps Publish refusing. + * so a pillar left behind never keeps Publish refusing — nor, since + * objectstack#26705, keeps the surface's departure guard asking. * * Keyed on the clause, a string, never on the view model's identity (AGENTS.md * #10); the reporter is read through a ref, so an unmemoised one cannot re-fire it. @@ -438,6 +439,18 @@ function useHeldEditReport(report: ((clause: string | null) => void) | undefined React.useEffect(() => () => reportRef.current?.(null), []); } +/** + * objectstack#26705 — the one question Studio asks before a departure drops + * unsaved work: a dirty pillar's edits (objectui#2600) or an edit a pillar's + * autosave holds unsent (objectui#11786). The surface asks it before the header + * leaves the pillar; a pillar asks it before its rail opens another item, which + * reloads the one buffer the work lives in. `true` when nothing would be lost + * or the author agrees to lose it. + */ +function confirmDropUnsavedWork(unsaved: boolean, locale: string): boolean { + return !unsaved || window.confirm(t('engine.edit.unsavedLeaveConfirm', locale)); +} + /** * 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 @@ -1326,15 +1339,24 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React // Pillars reset their report on unmount, so a confirmed discard clears the // flag by itself. const [pillarDirty, setPillarDirty] = React.useState(false); - const confirmLeavePillar = React.useCallback(() => { - if (!pillarDirty) return true; - return window.confirm(t('engine.edit.unsavedLeaveConfirm', locale)); - }, [pillarDirty, locale]); + // objectui#11786 — what the open pillar's autosave holds unsent (an + // incomplete-but-normal edit), as the clause naming what it needs. A publish + // promotes the drafts the server holds, which do not carry that edit, so it is + // refused while one is held rather than going around it silently. + const [heldEdit, setHeldEdit] = React.useState(null); + // objectstack#26705 — a held edit lives only in the pillar's buffer, so a + // departure drops it exactly as it drops a dirty pillar's edits: both are + // unsaved work, and every guard below reads this one bit. + const unsavedPillarWork = pillarDirty || heldEdit !== null; + const confirmLeavePillar = React.useCallback( + () => confirmDropUnsavedWork(unsavedPillarWork, locale), + [unsavedPillarWork, locale], + ); // Browser-native "leave site?" prompt on tab close / reload while a pillar // is dirty. The matrix editor installs its own (double registration is // harmless); the Interfaces nav editor has none, so this closes that gap. React.useEffect(() => { - if (!pillarDirty) return; + if (!unsavedPillarWork) return; const handler = (e: BeforeUnloadEvent) => { e.preventDefault(); // Required for Chrome to actually show the prompt. @@ -1342,13 +1364,13 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React }; window.addEventListener('beforeunload', handler); return () => window.removeEventListener('beforeunload', handler); - }, [pillarDirty]); + }, [unsavedPillarWork]); // objectui#13253 — the shell's own departures (header menus, palette, inbox, // AI entry) leave from buttons this surface never sees, so a dirty pillar // registers with the shell's seam, with the same question the header asks. // While registered, the seam's link guard also asks before a pillar link // leaves, so the pillar links below no longer ask a second time. - useRegisterUnsavedEdits(pillarDirty, t('engine.edit.unsavedLeaveConfirm', locale)); + useRegisterUnsavedEdits(unsavedPillarWork, t('engine.edit.unsavedLeaveConfirm', locale)); // The shared authenticated metadata client for this surface. Declared here // rather than beside its other reader below because the publish immediately @@ -1422,12 +1444,6 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React refreshPendingRef.current = refreshPending; }); - // objectui#11786 — what the open pillar's autosave holds unsent (an - // incomplete-but-normal edit), as the clause naming what it needs. A publish - // promotes the drafts the server holds, which do not carry that edit, so it is - // refused while one is held rather than going around it silently. - const [heldEdit, setHeldEdit] = React.useState(null); - // `options` is the review sheet's (objectui#12929): its checked backfill // choices, or nothing. Only the package batch door below carries it; the // sheet offers no choice in the package-less scope. @@ -5203,6 +5219,9 @@ export function InterfacesPillar({ active={current} objectIcons={objectIconMap} onPick={(s) => { + // objectstack#26705 — another leaf's load replaces the buffer + // the held widget lives in; an entry for the open leaf keeps it. + if (leafKeyOf(s) !== leafKey && !confirmDropUnsavedWork(leafHeld !== null, locale)) return; setCurrent(s); if (isMobile) setRailOpen(false); }} @@ -6308,6 +6327,9 @@ export function DataPillar({ key={o.name} active={current?.name === o.name} onClick={() => { + // objectstack#26705 — another object's load replaces the + // buffer the held edit lives in; the open one keeps it. + if (current?.name !== o.name && !confirmDropUnsavedWork(objHeld !== null, locale)) return; setCurrent(o); if (isMobile) setRailOpen(false); }} @@ -7956,6 +7978,9 @@ export function AutomationsPillar({ data-trigger={glyph} title={[f.label, t(FLOW_GLYPH_LABEL_KEY[glyph], locale), reading?.view.label].filter(Boolean).join(' · ')} onClick={() => { + // objectstack#26705 — another flow's load replaces the + // buffer the held step lives in; the open one keeps it. + if (!active && !confirmDropUnsavedWork(flowHeld !== null, locale)) return; setCurrent(f); if (isMobile) setRailOpen(false); }} @@ -8473,10 +8498,10 @@ export function AccessPillar({ }, [], ); - const confirmDiscardEdits = React.useCallback(() => { - if (!pillarDirty) return true; - return window.confirm(t('engine.edit.unsavedLeaveConfirm', locale)); - }, [pillarDirty, locale]); + const confirmDiscardEdits = React.useCallback( + () => confirmDropUnsavedWork(pillarDirty, locale), + [pillarDirty, locale], + ); const load = React.useCallback(async () => { try {