From 63fcb6a53cb60c6fd037409c5b427e0620cbfef4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 17:32:38 +0000 Subject: [PATCH 1/2] test(app-shell): pin lone-widget span and one canvas frame (objectui#8219) RED against the unfixed preview: a lone dashboard widget keeps its authored half span inside a grid pinned to a 768px minimum, and the Studio canvas wraps the preview's own PreviewShell in a second bordered, padded card. Multi-widget layouts and shell-less canvas states are pinned unchanged. Co-Authored-By: Claude --- .../DashboardPreview.loneWidget.test.tsx | 139 ++++++++++++++++++ .../StudioDesignSurface.canvasFrame.test.tsx | 130 ++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.loneWidget.test.tsx create mode 100644 packages/app-shell/src/views/studio-design/StudioDesignSurface.canvasFrame.test.tsx diff --git a/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.loneWidget.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.loneWidget.test.tsx new file mode 100644 index 0000000000..da0e80e688 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.loneWidget.test.tsx @@ -0,0 +1,139 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#8219 item 1 — how wide a dashboard's widgets are in the designer + * preview. + * + * Measured on a live render: inside the Studio canvas the preview pinned the + * dashboard grid to a 768px minimum, and a dashboard with ONE widget showed it + * at its authored half span, so the only chart on the page used half of a grid + * that was itself wider than the canvas. The filer's direction: a lone widget + * fills the grid width. + * + * Pinned both ways, through the REAL `DashboardRenderer`: + * - one widget: it spans every column of the grid, and the grid carries no + * 768px minimum; + * - several widgets: the grid keeps the 768px minimum and every widget keeps + * its authored span, so multi-widget dashboards lay out exactly as before. + * + * Height is not overridden: rows keep the renderer's floor per authored row + * (`layout.h`) and grow with the widget's content. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, waitFor } from '@testing-library/react'; +// Module-scope import of the lazily loaded renderer, so the preview's +// `React.lazy` factory resolves at once instead of racing the test's wait +// window (AGENTS.md, flaky-test discipline). +import '@object-ui/plugin-dashboard'; + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => ({}) }; +}); + +import { DashboardPreview } from './DashboardPreview'; + +afterEach(cleanup); + +type Widget = Record; + +function renderPreview(widgets: Widget[], editing = true) { + return render( + {}} + onPatch={() => {}} + locale="en-US" + />, + ); +} + +/** The dashboard grid host and the widget cells laid out on it. */ +async function readGrid(container: HTMLElement, count: number) { + return waitFor(() => { + const cells = Array.from(container.querySelectorAll('[style*="grid-column"]')); + if (cells.length !== count) throw new Error(`expected ${count} widget cells, saw ${cells.length}`); + const grid = cells[0].parentElement!; + const template = grid.style.gridTemplateColumns; + const columns = Number(/^repeat\((\d+),/.exec(template)?.[1]); + if (!Number.isFinite(columns) || columns < 1) { + throw new Error(`grid has no positioned column template: "${template}"`); + } + return { grid, cells, columns }; + }); +} + +function spanOf(cell: HTMLElement): number { + return Number(/^span (\d+)$/.exec(cell.style.gridColumn)?.[1]); +} + +function hasMinWidthPin(container: HTMLElement): boolean { + return container.querySelector('.min-w-\\[768px\\]') !== null; +} + +// The widget the live measurement was taken on: one chart, authored at half of +// a 12-column grid. +const LONE_CHART: Widget = { + id: 'by_industry', + type: 'chart', + title: 'By industry', + layout: { x: 0, y: 0, w: 6, h: 4 }, +}; + +describe('DashboardPreview — a lone widget fills the grid (objectui#8219)', () => { + it('one widget authored at half span spans every grid column, with no 768px minimum', async () => { + const { container } = renderPreview([LONE_CHART]); + const { cells, columns } = await readGrid(container, 1); + expect(spanOf(cells[0])).toBe(columns); + expect(hasMinWidthPin(container)).toBe(false); + // Height is the authored row count, not a new fixed height. + expect(cells[0].style.gridRow).toBe('span 4'); + }); + + it('one layout-less widget spans every grid column too', async () => { + const { container } = renderPreview([{ id: 'total', type: 'metric', title: 'Total' }]); + const { cells, columns } = await readGrid(container, 1); + expect(spanOf(cells[0])).toBe(columns); + expect(hasMinWidthPin(container)).toBe(false); + }); + + it('the run (non-design) preview of one widget fills the grid as well', async () => { + const { container } = renderPreview([LONE_CHART], false); + const { cells, columns } = await readGrid(container, 1); + expect(spanOf(cells[0])).toBe(columns); + expect(hasMinWidthPin(container)).toBe(false); + }); +}); + +describe('DashboardPreview — several widgets keep their layout (objectui#8219)', () => { + it('keeps the 768px minimum, the 12-column grid and every authored span', async () => { + const { container } = renderPreview([ + { id: 'k1', type: 'metric', title: 'Revenue', layout: { x: 0, y: 0, w: 3, h: 2 } }, + { id: 'k2', type: 'metric', title: 'Orders', layout: { x: 3, y: 0, w: 3, h: 2 } }, + LONE_CHART, + ]); + const { cells, columns } = await readGrid(container, 3); + expect(columns).toBe(12); + expect(cells.map(spanOf)).toEqual([3, 3, 6]); + expect(cells.map((c) => c.style.gridRow)).toEqual(['span 2', 'span 2', 'span 4']); + expect(hasMinWidthPin(container)).toBe(true); + }); + + it('two layout-less widgets keep the renderer defaults and the 768px minimum', async () => { + const { container } = renderPreview([ + { id: 'c1', type: 'chart', title: 'A', layout: { x: 0, y: 0, w: 6, h: 4 } }, + { id: 'c2', type: 'chart', title: 'B' }, + ]); + const { cells, columns } = await readGrid(container, 2); + expect(columns).toBe(12); + expect(cells.map(spanOf)).toEqual([6, 6]); + expect(hasMinWidthPin(container)).toBe(true); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.canvasFrame.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.canvasFrame.test.tsx new file mode 100644 index 0000000000..b45af45a9a --- /dev/null +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.canvasFrame.test.tsx @@ -0,0 +1,130 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#8219 item 1 — one frame around the Interfaces canvas preview. + * + * The canvas wrapper drew its own bordered, padded card, and the preview it + * hosts draws another (`PreviewShell`), so a dashboard sat inside two frames + * and paid for both borders and the wrapper's padding. The filer's direction: + * 「预览层只留一层边框」. + * + * Pinned: with a registered preview on the canvas, exactly ONE bordered + * element sits between the canvas `main` and the dashboard grid (the + * preview's own shell). Canvas states that bring no shell of their own (the + * "no designer registered" state here) keep the wrapper's frame. + */ +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +// Module-scope import of the lazily loaded dashboard renderer (AGENTS.md, +// flaky-test discipline). +import '@object-ui/plugin-dashboard'; + +const NAV = [ + { id: 'nav_dash', type: 'dashboard', label: 'Customers', dashboardName: 'customer_dashboard' }, + // Only the dashboard designer is registered below, so this leaf renders the + // canvas's own "no designer" state, which brings no shell. + { id: 'nav_rep', type: 'report', label: 'Sales report', reportName: 'sales_report' }, +]; + +const DASHBOARD = { + name: 'customer_dashboard', + label: 'Customers', + widgets: [ + { id: 'by_industry', type: 'chart', title: 'By industry', layout: { x: 0, y: 0, w: 6, h: 4 } }, + ], +}; + +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 === 'customer_dashboard') 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( + + + , + ); +} + +/** Elements between `from` (exclusive) and the canvas `main` (exclusive) that draw a border. */ +function borderedAncestors(from: HTMLElement): HTMLElement[] { + const out: HTMLElement[] = []; + let el = from.parentElement; + while (el && el.tagName !== 'MAIN') { + if (el.classList.contains('border')) out.push(el); + el = el.parentElement; + } + expect(el?.tagName).toBe('MAIN'); + return out; +} + +describe('Interfaces canvas — one frame around a preview (objectui#8219)', () => { + it('a dashboard preview sits inside exactly one bordered frame, its own PreviewShell', async () => { + const { container } = renderPillar(); + fireEvent.click(await screen.findByTitle('dashboard · customer_dashboard')); + const grid = await waitFor( + () => { + const cell = container.querySelector('[style*="grid-column"]'); + if (!cell) throw new Error('dashboard grid not rendered yet'); + return cell.parentElement!; + }, + { timeout: 4000 }, + ); + const frames = borderedAncestors(grid); + expect(frames).toHaveLength(1); + // The one frame left is the preview's shell, not the canvas wrapper's card: + // the wrapper's padding went with its border. + expect(frames[0].className).toMatch(/\boverflow-hidden\b/); + expect(container.querySelector('main .rounded-lg.border.p-4')).toBeNull(); + }); + + it('a canvas state with no preview shell keeps the wrapper frame', async () => { + renderPillar(); + fireEvent.click(await screen.findByTitle('report · sales_report')); + const empty = await screen.findByText(/cannot be previewed or designed here/, undefined, { + timeout: 4000, + }); + const frames = borderedAncestors(empty); + expect(frames).toHaveLength(1); + expect(frames[0].className).toMatch(/\bp-4\b/); + }); +}); From 20932c30c4861443b43387dca7cf27c5f61e7885 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 17:33:57 +0000 Subject: [PATCH 2/2] fix(app-shell): lone dashboard widget fills the preview; one Studio canvas frame (objectui#8219) DashboardPreview renders a single-widget draft as a one-column grid, so the lone widget spans the full width, and keeps the 768px minimum only for several widgets. The Studio canvas wrapper drops its border and padding when it hosts a registered preview, which brings its own PreviewShell frame. Co-Authored-By: Claude --- .../8219-lone-widget-one-canvas-frame.md | 18 ++++++++++++++++ .../previews/DashboardPreview.tsx | 21 ++++++++++++++++--- .../studio-design/StudioDesignSurface.tsx | 19 ++++++++++++++++- 3 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 .changeset/8219-lone-widget-one-canvas-frame.md diff --git a/.changeset/8219-lone-widget-one-canvas-frame.md b/.changeset/8219-lone-widget-one-canvas-frame.md new file mode 100644 index 0000000000..177ade016b --- /dev/null +++ b/.changeset/8219-lone-widget-one-canvas-frame.md @@ -0,0 +1,18 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(app-shell): a dashboard's lone widget fills the designer preview, and the Studio canvas draws one frame + +In the dashboard designer preview, a dashboard with a single widget showed it +at its authored span (a half-width chart used half the grid), inside a grid +pinned to a 768px minimum that was wider than the Studio canvas. A lone widget +now spans the whole grid, and the 768px minimum applies only when there are +several widgets. Multi-widget dashboards lay out exactly as before, and the +widget keeps its authored row count. The preview never writes this layout back +to the draft. + +The Studio Interfaces canvas no longer wraps a registered preview in a second +bordered, padded card: the preview's own frame is the only one. Canvas states +without a preview frame (empty, loading, no designer, the records grid) keep +the card. diff --git a/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.tsx index 584da650c2..663561f781 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.tsx @@ -115,6 +115,19 @@ export function DashboardPreview({ ? widgets.find((w) => w?.id === selectedWidgetId) ?? null : null; + // objectui#8219 — a dashboard with ONE widget shows it across the whole + // grid. The renderer sizes each widget by its authored span (a half-width + // chart is `w: 6` of 12), which leaves a lone widget using half the canvas + // beside empty space. Rendering that draft as a one-column grid gives the + // lone widget the full width while its authored row count (`layout.h`) and + // the renderer's content-sized rows stay as they are. Preview only: the + // draft handed to `onPatch` is never this copy. + const loneWidget = widgets.length === 1; + const renderedSchema = React.useMemo( + () => (loneWidget ? { ...(draft as Record), columns: 1 } : draft), + [draft, loneWidget], + ); + const addButton = canEdit ? ( ) : null; @@ -158,11 +171,13 @@ export function DashboardPreview({ * characters ("管道…"). Pin a desktop-like minimum width so * the grid lays out as end users see it; the parent's * overflow-auto adds a horizontal scrollbar when the canvas is - * narrower. + * narrower. The minimum applies only with several widgets: a lone + * widget fills the grid instead (objectui#8219), so it has no row + * to squeeze and a pinned width would only add a scrollbar. */} -
+
@@ -2073,7 +2086,11 @@ export function InterfacesPillar({ // Source pages: let the live preview fill the canvas height (it // brings its own PreviewShell chrome), so it balances the taller // editor panel instead of floating as a short card. - isSourcePage ? 'min-h-0 flex-1 overflow-hidden' : 'rounded-lg border bg-background p-4', + isSourcePage + ? 'min-h-0 flex-1 overflow-hidden' + : canvasHostsPreviewShell + ? undefined + : 'rounded-lg border bg-background p-4', )} > {appStatus === 'missing' && !error ? (