diff --git a/.changeset/7121-studio-canvas-leaf-affordances.md b/.changeset/7121-studio-canvas-leaf-affordances.md new file mode 100644 index 0000000000..fee18dc171 --- /dev/null +++ b/.changeset/7121-studio-canvas-leaf-affordances.md @@ -0,0 +1,31 @@ +--- +'@object-ui/app-shell': patch +--- + +Studio Interfaces: no Design mode and no "click a block" rail on leaves that have +no block canvas (objectui#7121). + +`registerStudioCanvasPreview(type, …)` opts a type into a surface-specific canvas +that renders the running app rather than an editable draft — a contract, not a +habit: `StudioCanvasPreviewProps` carries no `selection`, `onSelectionChange`, +`onPatch` or `editing`. Two affordances beside such a leaf ignored that. + +- The Design/Run switch (objectui#5800) was still offered, though `editing` is + handed to exactly one canvas branch (`Preview`). On a studio-canvas leaf the + switch moved `canvasMode` and reached no renderer — a live-looking control + wired to nothing. It is now gated. +- The right rail fell through to "Click a block on the canvas, and edit its + properties right here." beside a canvas that has no blocks, so the instruction + could not be followed. It now states what the canvas is, and — because this + canvas has no blocks by contract — promises no recovery. +- The rail's new branch is ordered ahead of the selection branch, so a block + selected on a *different* leaf no longer opens a scoped inspector for a block + this canvas does not contain; the header's "clear selection" button is gated + with it. + +The discriminator is `StudioCanvas`, not `isEditable`. `isEditable` is +`!!Preview && !StudioCanvas` — a conjunction of two independent causes — so +gating on it would also strip these affordances from leaves whose only fault is +that their own type has no designer, the state objectui#6795 part C pinned as +still deserving the ordinary rail. Behaviour on every leaf with a block canvas +is unchanged. diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index b04271c77f..d06ba2decb 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -1777,6 +1777,12 @@ const ENGINE_STRINGS_EN: Record = { 'engine.studio.inspector.tabSource': 'Source', 'engine.studio.inspector.emptyLine1': 'Click a block on the canvas,', 'engine.studio.inspector.emptyLine2': 'and edit its properties right here.', + // objectui#7121 — the rail beside a studio-canvas leaf (a + // `registerStudioCanvasPreview` type: the running app, not a block tree). + // States what the canvas IS; promises no recovery, because there is nothing + // to wait for — `StudioCanvasPreviewProps` carries no selection by contract. + 'engine.studio.inspector.studioCanvasNoBlocks': + 'This canvas renders the running app, not a block tree — it has no blocks to select, and nothing here is edited from this panel.', 'engine.studio.inspector.designersMissing': 'No metadata designers are registered in this session, so there is nothing to edit here.', 'engine.studio.inspector.noPageSchema': 'Page settings are unavailable — the page schema could not be loaded.', @@ -3639,6 +3645,8 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.studio.inspector.tabSource': '源码', 'engine.studio.inspector.emptyLine1': '在画布里点选一个积木,', 'engine.studio.inspector.emptyLine2': '它的属性会在这里直接编辑。', + 'engine.studio.inspector.studioCanvasNoBlocks': + '此画布渲染的是运行态应用,而不是积木树 —— 这里没有可选中的积木,也没有可在本面板编辑的内容。', 'engine.studio.inspector.designersMissing': '本次会话没有注册任何元数据设计器,这里没有可编辑的内容。', 'engine.studio.inspector.noPageSchema': '页面设置不可用——无法加载页面 schema。', 'engine.studio.inspector.sourcePageLine1': '这个页面是 {kind} 源码,不是积木树 ——', diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.studioCanvasLeaf.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.studioCanvasLeaf.test.tsx new file mode 100644 index 0000000000..ecc7d677e9 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.studioCanvasLeaf.test.tsx @@ -0,0 +1,300 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#7121 — what the Interfaces pillar may OFFER beside a studio-canvas + * leaf. + * + * ## The mechanism + * + * `registerStudioCanvasPreview(type, …)` opts a type into a surface-specific + * canvas: the running app, not an editable draft. That is a contract, not a + * habit — `StudioCanvasPreviewProps` is deliberately a small read-only subset + * of `MetadataPreviewProps` with **no `selection`, no `onSelectionChange`, no + * `onPatch`, no `editing`**. So a studio-canvas leaf: + * + * - can never produce a block selection (there is no block tree), and + * - can never read the design/run mode (nothing is handed `editing`). + * + * Two affordances beside it ignored both facts: the Design/Run switch (#5800) + * was still offered though `canvasMode` reached no renderer, and the rail fell + * through to *"Click a block on the canvas, and edit its properties right + * here."* — an instruction that cannot be followed. + * + * ## ⚠️ This is NOT #6795 part C's cause, and the precondition says so + * + * Part C repaired what the pillar says when the designer registries are + * **empty**. Every test here asserts a **POPULATED** registry first + * (`listMetadataPreviewTypes()` non-empty), because a zero-registry reading + * would measure that other card instead. The cause here is an ungated + * affordance, not a missing registration. + * + * ## ⛔ The message promises no recovery + * + * Part C established by measurement that these registries are plain `Map`s read + * during render with no subscription, so a consumer that read an empty one + * never recovers ("late inspector rendered: false") — no "loading…", no "try + * again". Here the constraint is even stricter: the statement is not about + * registration at all. This canvas has no blocks **by contract**, so there is + * nothing to wait for, and the last test pins the absence of recovery language. + * + * ## ⚠️ The discriminator is `StudioCanvas`, NOT `isEditable` + * + * `isEditable = !!Preview && !StudioCanvas` is a conjunction of two independent + * causes. Gating on it would also strip these affordances from leaves whose + * only fault is that **their own type** has no designer — the exact state + * `StudioDesignSurface.designerRegistryPartial.test.tsx` pins as still deserving + * the ordinary "click a block" rail. That file is the live fence: gate on + * `isEditable` and it goes red. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +const objectDef = { + name: 'showcase_task', + label: 'Task', + fields: [{ name: 'title', label: 'Title', type: 'text' }], +}; + +/** + * One leaf of each kind, in one app: `object` opts into the studio canvas, + * `dashboard` does not (it is an ordinary block-canvas designer). The contrast + * is the point — the second leaf is the regression fence. + */ +const NAV = [ + { id: 'nav_obj', type: 'object', label: 'Tasks', objectName: 'showcase_task' }, + { id: 'nav_dash', type: 'dashboard', label: 'Overview', dashboardName: 'sales_overview' }, +]; + +const mockClient = { + save: vi.fn(async () => ({})), + list: vi.fn(async (type: string) => { + if (type === 'app') return [{ name: 'acme_app', label: 'Acme' }]; + if (type === 'object') return [{ name: 'showcase_task', label: 'Task' }]; + return []; + }), + 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 === 'object') return { effective: objectDef, code: objectDef }; + if (type === 'dashboard') return { effective: { name: 'sales_overview', label: 'Sales' } }; + return { effective: { name } }; + }), + getDraft: vi.fn(async () => null), + 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 { listMetadataPreviewTypes, registerMetadataPreview } from '../metadata-admin/preview-registry'; +import { listMetadataInspectorTypes, registerMetadataInspector } from '../metadata-admin/inspector-registry'; +import { listStudioCanvasPreviewTypes } from './studio-canvas-preview'; + +const CLICK_A_BLOCK = 'Click a block on the canvas,'; + +/** + * A block-canvas designer for an unrelated type. Two jobs: it makes the + * designer registry demonstrably POPULATED (the card's precondition), and it + * is the regression fence's leaf. It reports the `editing` prop it was handed, + * so the Design/Run round trip is measured at the seam that actually carries + * the mode rather than through any one renderer's overlay internals. + */ +function StubDashboardPreview(props: Record): React.ReactElement { + const onSel = props.onSelectionChange as ((s: unknown) => void) | undefined; + return ( +
+ +
+ ); +} +registerMetadataPreview('dashboard', StubDashboardPreview as never); + +/** + * Production registers `ObjectFieldInspector` for `object` + * (`metadata-admin/inspectors/index.ts`), so the scoped-inspector branch is + * reachable on the studio-canvas leaf whenever a `selection` survives a leaf + * change. Registering a stand-in here is what makes the last pin a measurement + * of that branch rather than of an accidentally-empty registry. + */ +function StubObjectInspector(props: Record): React.ReactElement { + const sel = props.selection as { kind?: string; id?: string } | null; + return ( +
+ ); +} +registerMetadataInspector('object', StubObjectInspector as never); + +afterEach(cleanup); + +function mountPillar() { + return render( + + + , + ); +} + +/** The precondition every test here shares, stated rather than assumed. */ +function expectPopulatedRegistries() { + expect(listMetadataPreviewTypes()).toContain('dashboard'); + expect(listMetadataPreviewTypes().length).toBeGreaterThan(0); + expect(listMetadataInspectorTypes()).toContain('object'); + // ...and the leaf under test really is a studio-canvas leaf. + expect(listStudioCanvasPreviewTypes()).toContain('object'); +} + +async function openLeaf(title: string) { + mountPillar(); + fireEvent.click(await screen.findByTitle(title)); +} + +const bodyText = () => (document.body.textContent ?? '').replace(/\s+/g, ' '); + +describe('Interfaces pillar — affordances beside a studio-canvas leaf (#7121)', () => { + it('offers no Design/Run switch on a leaf whose canvas cannot read the mode', async () => { + expectPopulatedRegistries(); + await openLeaf('object · showcase_task'); + + // Wait on the canvas itself, not on the toggle — the toggle's ABSENCE is + // the assertion, so waiting for it would deadlock the pin by construction. + await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 }); + + expect(screen.queryByTestId('canvas-mode-toggle')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Design' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Run' })).not.toBeInTheDocument(); + }); + + it('replaces the impossible "click a block" invitation with what is true', async () => { + expectPopulatedRegistries(); + await openLeaf('object · showcase_task'); + await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 }); + + await waitFor(() => + expect(bodyText()).toContain('This canvas renders the running app, not a block tree'), + ); + // ⛔ The invitation that cannot be followed must be gone. + expect(bodyText()).not.toContain(CLICK_A_BLOCK); + // ⛔ And this must not be mistaken for #6795 part C's empty-registry state, + // which is demonstrably not the cause here. + expect(bodyText()).not.toContain('No metadata designers are registered'); + }); + + it('promises no recovery — there is nothing to wait for', async () => { + expectPopulatedRegistries(); + await openLeaf('object · showcase_task'); + await waitFor(() => expect(bodyText()).toContain('This canvas renders the running app'), { + timeout: 4000, + }); + + // Scoped to the RAIL, not the document: the canvas beside it renders the + // real records grid, which in jsdom (no data source) says "Error loading + // grid" — its own honest state, and nothing this card may speak for. A + // document-wide scan would read that as the rail promising recovery. + const railBlock = screen.getByText(/This canvas renders the running app/).closest('div'); + const rail = railBlock?.textContent ?? ''; + // Control: the scoped read must actually have found the message. + expect(rail).toContain('This canvas renders the running app'); + + for (const promise of ['Loading', 'loading', 'try again', 'Try again', 'not yet', 'in progress']) { + expect(rail).not.toContain(promise); + } + }); + + it('does not open a scoped inspector for a block selected on a DIFFERENT leaf', async () => { + expectPopulatedRegistries(); + + // Select a block on the dashboard leaf — a real selection, made through the + // pillar's own `onSelectionChange`. + await openLeaf('dashboard · sales_overview'); + await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), { + timeout: 4000, + }); + fireEvent.click(screen.getByTestId('pick-block')); + await waitFor(() => expect(screen.getByLabelText('Clear selection')).toBeInTheDocument(), { + timeout: 4000, + }); + + // ...then walk to the studio-canvas leaf. The pillar's load effect clears + // `selection` only on the editable path, so the selection is still live. + fireEvent.click(screen.getByTitle('object · showcase_task')); + await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 }); + + // The block belongs to another leaf's canvas and does not exist on this one. + expect(screen.queryByTestId('stub-object-inspector')).not.toBeInTheDocument(); + expect(bodyText()).toContain('This canvas renders the running app, not a block tree'); + // ...and the rail must not contradict itself by offering to clear a + // selection it just said cannot exist here. + expect(screen.queryByLabelText('Clear selection')).not.toBeInTheDocument(); + }); +}); + +describe('REGRESSION FENCE — a leaf WITH a block canvas is untouched (#7121)', () => { + it('still offers the Design/Run switch, and it still round-trips', async () => { + expectPopulatedRegistries(); + await openLeaf('dashboard · sales_overview'); + await waitFor(() => expect(screen.getByTestId('canvas-mode-toggle')).toBeInTheDocument(), { + timeout: 4000, + }); + + // #5800's acceptance: 设计⇄运行 is a round trip on the SAME renderer, and + // `editing` is the mode the renderer actually reads. + expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'true'); + fireEvent.click(screen.getByRole('button', { name: 'Run' })); + await waitFor(() => + expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'false'), + ); + fireEvent.click(screen.getByRole('button', { name: 'Design' })); + await waitFor(() => + expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'true'), + ); + }); + + it('keeps the ordinary "click a block" rail where a block canvas exists', async () => { + expectPopulatedRegistries(); + await openLeaf('dashboard · sales_overview'); + await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), { + timeout: 4000, + }); + + // The repair must not over-fire: this leaf HAS blocks to click. + await waitFor(() => expect(bodyText()).toContain(CLICK_A_BLOCK)); + expect(bodyText()).not.toContain('This canvas renders the running app'); + }); + + it('keeps the selection affordances where a block canvas exists', async () => { + expectPopulatedRegistries(); + await openLeaf('dashboard · sales_overview'); + await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), { + timeout: 4000, + }); + + fireEvent.click(screen.getByTestId('pick-block')); + await waitFor(() => expect(screen.getByLabelText('Clear selection')).toBeInTheDocument()); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index d5abcd2d3a..5209c038d4 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -1576,7 +1576,19 @@ export function InterfacesPillar({
{/* objectui#5800 — the 设计⇄运行 switch replaces the static 实时预览 chip: same renderer either way, the switch only adds/removes the - design affordances. */} + design affordances. + + objectui#7121 — ...but only where a renderer READS the mode. `editing` + is handed to exactly one canvas branch (`Preview`, below); a + studio-canvas leaf renders `StudioCanvas`, whose props + (`StudioCanvasPreviewProps`) carry no `editing` by contract — it is + the running app, not an editable draft. So on those leaves the + switch moved `canvasMode` and nothing else: a live-looking control + wired to nothing. Gated on `StudioCanvas` — the SAME value that + selects the canvas branch — and deliberately NOT on `isEditable`, + which is `!!Preview && !StudioCanvas` and would also strip the + switch from the no-designer leaves that #6795 part C pinned. */} + {!StudioCanvas && (
+ )} {current && ( {current.type} · {current.name} @@ -1693,7 +1706,13 @@ export function InterfacesPillar({ {t('engine.studio.inspector.props', locale)}
- {selection && ( + {/* objectui#7121 — same gate as the rail branch: on a studio-canvas + leaf `selection` can only be a leftover from the previous leaf, so + offering "clear selection" here would contradict the rail beside it, + which states this canvas has no blocks. (The leftover STATE itself + outlives every non-editable leaf change, not just these — filed + separately rather than swept in here.) */} + {selection && !StudioCanvas && (
+ ) : StudioCanvas && current ? ( + // ⭐ objectui#7121 — a studio-canvas leaf has no block tree, so the + // generic "click a block on the canvas" invitation below instructs the + // author to do something impossible: `StudioCanvasPreviewProps` carries + // no `selection`/`onSelectionChange` by contract, so this canvas can + // never produce one. Measured with the designer registry POPULATED + // (`listMetadataPreviewTypes() === ['dashboard']`), which is what makes + // this a different cause from #6795 part C's empty-registry states. + // + // Ordered BEFORE the `selection` branch on purpose. `selection` is state + // that outlives a leaf change (the load effect clears it only on the + // editable path), so arriving here with a block selected on the PREVIOUS + // leaf otherwise opens the scoped inspector for a block this canvas does + // not contain — measured: `ObjectFieldInspector` handed + // `object:showcase_task:block:blk_1`. This ordering mirrors the canvas + // chain, where `StudioCanvas` also wins. + // + // ⛔ Says what is true and promises no recovery — no "loading…", no "try + // again". Same constraint part C established: these registries are plain + // `Map`s read during render with no subscription. Here the statement is + // not even about registration — this canvas has no blocks by contract, + // so there is nothing to wait for. +
+
+ + {t('engine.studio.inspector.studioCanvasNoBlocks', locale)} +
+
) : selection && Inspector && current ? (