diff --git a/.changeset/canvas-design-run-5800.md b/.changeset/canvas-design-run-5800.md new file mode 100644 index 0000000000..18e7c49388 --- /dev/null +++ b/.changeset/canvas-design-run-5800.md @@ -0,0 +1,5 @@ +--- +'@object-ui/app-shell': minor +--- + +设计⇄运行 on the Interfaces canvas (#5800): a two-state switch in the canvas header flips the SAME renderer between design (selection + inspector + design overlays) and an interactive runtime (click 新建, enter records) — ADR-0080's design=run pivot made visible; selection context survives the round trip. The topbar's 打开应用 teleport is retired (run mode is the in-workbench way to try the app), and the topbar's app detection now matches the pillar's (draft-app fallback, re-resolved on draft saves and the metadata-refresh pulse) so a deep-link to /access can no longer claim the package has no app while /data shows one. diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index c08c5df4c0..b8f32130c5 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -1740,6 +1740,8 @@ const ENGINE_STRINGS_EN: Record = { 'engine.studio.if.noApp': 'This package has no app yet.', 'engine.studio.if.noNavItems': 'No nav items yet — (click “Edit” above to add)', 'engine.studio.if.previewIsRuntime': 'Live preview', + 'engine.studio.if.modeDesign': 'Design', + 'engine.studio.if.modeRun': 'Run', 'engine.studio.if.tabCanvas': 'Canvas', 'engine.studio.if.noAppTitle': 'This package has no app yet', 'engine.studio.if.noAppHint': 'Create an app to design its navigation and interfaces.', @@ -3591,6 +3593,8 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.studio.if.noApp': '这个软件包还没有应用。', 'engine.studio.if.noNavItems': '还没有导航项 —(点上方「编辑」添加)', 'engine.studio.if.previewIsRuntime': '实时预览', + 'engine.studio.if.modeDesign': '设计', + 'engine.studio.if.modeRun': '运行', 'engine.studio.if.tabCanvas': '画布', 'engine.studio.if.noAppTitle': '这个软件包还没有应用', 'engine.studio.if.noAppHint': '创建一个应用来设计它的导航与界面。', diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.canvasMode.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.canvasMode.test.tsx new file mode 100644 index 0000000000..4411404b44 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.canvasMode.test.tsx @@ -0,0 +1,119 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#5800 — the 设计⇄运行 canvas switch (cloud#1609 增量二). + * + * ADR-0080's pivot (design state = run state, one renderer) made visible: the + * Interfaces canvas header carries a two-state switch; RUN mode is pure + * subtraction — `editing=false` drops the design overlays so the SAME + * renderer serves the interactive runtime. Pinned through the dashboard leaf + * because its design mode is the most explicit: `DashboardRenderer` swallows + * widget interaction behind `widget-click-overlay` elements exactly when + * designMode is on, so the overlay's presence IS the mode. + * + * Also pinned: selection context survives a run round-trip (the acceptance's + * 「切回设计不丢」), via the switch not clearing the pillar's selection state. + */ +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +const NAV = [ + { id: 'nav_dash', type: 'dashboard', label: 'Overview', dashboardName: 'sales_overview' }, +]; + +const DASHBOARD = { + name: 'sales_overview', + label: 'Sales Overview', + widgets: [ + { id: 'w1', type: 'metric', title: 'Total', options: { value: 42 } }, + ], +}; + +const mockClient = { + list: vi.fn(async (type: string) => + type === 'app' ? [{ name: 'acme_app', label: 'Acme' }] : [], + ), + listDrafts: vi.fn(async () => []), + layered: vi.fn(async (type: string, name: string) => { + if (type === 'app') return { effective: { name: 'acme_app', label: 'Acme', navigation: NAV } }; + if (type === 'dashboard' && name === 'sales_overview') return { effective: DASHBOARD }; + return { effective: { name } }; + }), + getDraft: vi.fn(async () => null), + save: vi.fn(async () => ({})), + get: vi.fn(async () => undefined), +}; + +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 () => []) }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => ({}) }; +}); + +import { InterfacesPillar } from './StudioDesignSurface'; +import { registerMetadataPreview } from '../metadata-admin/preview-registry'; +import { DashboardPreview } from '../metadata-admin/previews/DashboardPreview'; + +registerMetadataPreview('dashboard', DashboardPreview); + +afterEach(cleanup); + +function renderPillar() { + return render( + + + , + ); +} + +async function openDashboardLeaf() { + renderPillar(); + fireEvent.click(await screen.findByTitle('dashboard · sales_overview')); + await waitFor(() => expect(screen.getByTestId('canvas-mode-toggle')).toBeInTheDocument(), { + timeout: 4000, + }); +} + +describe('Interfaces canvas — 设计⇄运行 switch (objectui#5800)', () => { + it('design mode (default) swallows widget interaction behind the design overlay', async () => { + await openDashboardLeaf(); + await waitFor( + () => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0), + { timeout: 4000 }, + ); + }); + + it('run mode removes the overlays — the SAME renderer serves the interactive runtime', async () => { + await openDashboardLeaf(); + await waitFor( + () => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0), + { timeout: 4000 }, + ); + fireEvent.click(screen.getByRole('button', { name: 'Run' })); + await waitFor(() => expect(screen.queryAllByTestId('widget-click-overlay')).toHaveLength(0), { + timeout: 4000, + }); + // ...and back: the switch is a round trip, not a one-way door. + fireEvent.click(screen.getByRole('button', { name: 'Design' })); + await waitFor( + () => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0), + { timeout: 4000 }, + ); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index dc8d8655d1..70f199098b 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -91,7 +91,7 @@ import { } from '../metadata-admin/nav-selection.js'; import { SourcePageEditor } from '../metadata-admin/previews/SourcePageEditor.js'; import { usePendingDrafts } from '../../preview/usePendingDrafts.js'; -import { emitMetadataRefresh } from '../../assistant/assistantBus.js'; +import { emitMetadataRefresh, subscribeMetadataRefresh } from '../../assistant/assistantBus.js'; import { formatMetadataError, formatPublishFailures, type PublishFailure } from './metadataError.js'; import { loadPackageSurfaces } from './packageSurfaces.js'; import { resolveSurface, findSurfaceInTree, type NavNode, type Surface } from './navSurface.js'; @@ -653,23 +653,35 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React [appAddObjects, loadPackageObjects, shellClient, packageId, locale], ); - React.useEffect(() => { - let cancelled = false; - (async () => { - try { - const apps = (await shellClient.list('app', { packageId })) as Array>; - const first = (apps || []) - .map((a) => ({ name: String(a.name ?? ''), label: String(a.label ?? a.name ?? '') })) - .filter((a) => a.name)[0]; - if (!cancelled) setPackageApp(first ?? null); - } catch { - if (!cancelled) setPackageApp(null); + // objectui#5800 顺手修 — the topbar's app detection used to disagree with the + // Interfaces pillar's (published-only read, no draftNonce dep, no refresh + // subscription, and never re-run on a pillar switch since /data and /access + // share one route element): a deep-link to /access could report 「还没有应用」 + // while /data showed the app at the same moment. Same resolution as the + // pillar now: published first, DRAFT app fallback, re-resolved on draft + // saves and on the metadata-refresh pulse. + const resolvePackageApp = React.useCallback(async (): Promise => { + try { + const apps = (await shellClient.list('app', { packageId })) as Array>; + let first = (apps || []) + .map((a) => ({ name: String(a.name ?? ''), label: String(a.label ?? a.name ?? '') })) + .filter((a) => a.name)[0]; + if (!first) { + const drafts = await shellClient.listDrafts?.({ packageId, type: 'app' }); + const d = drafts?.[0] as { name?: unknown; label?: unknown } | undefined; + if (d?.name) first = { name: String(d.name), label: String(d.label ?? d.name) }; } - })(); - return () => { - cancelled = true; - }; - }, [shellClient, packageId, publishNonce]); + setPackageApp(first ?? null); + } catch { + setPackageApp(null); + } + }, [shellClient, packageId]); + React.useEffect(() => { + void resolvePackageApp(); + return subscribeMetadataRefresh(() => { + void resolvePackageApp(); + }); + }, [resolvePackageApp, publishNonce, draftNonce]); // ADR-0057 P3 — the decided Studio grid: `[left: nav/tree] [center: canvas + // properties] [right: chat]`. NOT keyed on the async agent catalog (that @@ -818,17 +830,10 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React {/* Package-level draft review + one atomic publish (replaces per-item 发布) */}
- {packageApp ? ( - - ) : appDraftPending ? ( + {/* objectui#5800 — the 打开应用 teleport is retired: the canvas's 运行 + mode IS the way to try the app without leaving the workbench. + The published-app state needs no chrome at all. */} + {packageApp ? null : appDraftPending ? ( ('design'); + const designing = canvasMode === 'design'; // `kind: 'html'`/`'react'` pages are a `source` string (ADR-0080/0081), // rendered by SourcePageEditor as a code-editor + live-preview split — there // is no block tree, so `selection` never populates and the generic "click a @@ -1535,9 +1547,33 @@ export function InterfacesPillar({ const canvasEl = (
- - {t('engine.studio.if.previewIsRuntime', locale)} - + {/* objectui#5800 — the 设计⇄运行 switch replaces the static 实时预览 + chip: same renderer either way, the switch only adds/removes the + design affordances. */} +
+ + +
{current && ( {current.type} · {current.name} @@ -1599,9 +1635,9 @@ export function InterfacesPillar({ type={current.type} name={current.name} draft={draft} - editing - selection={selection} - onSelectionChange={setSelection} + editing={designing} + selection={designing ? selection : null} + onSelectionChange={designing ? setSelection : undefined} onPatch={onPatch} locale={locale} />