Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/8219-lone-widget-one-canvas-frame.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<typeof import('@object-ui/react')>();
return { ...mod, useAdapter: () => ({}) };
});

import { DashboardPreview } from './DashboardPreview';

afterEach(cleanup);

type Widget = Record<string, unknown>;

function renderPreview(widgets: Widget[], editing = true) {
return render(
<DashboardPreview
type="dashboard"
name="customer_dashboard"
draft={{ name: 'customer_dashboard', label: 'Customers', widgets }}
editing={editing}
selection={null}
onSelectionChange={() => {}}
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<HTMLElement>('[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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>), columns: 1 } : draft),
[draft, loneWidget],
);

const addButton = canEdit ? (
<AddWidgetPicker onAdd={handleAddWidget} label={tr('engine.inspector.add.widget', locale)} />
) : null;
Expand Down Expand Up @@ -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.
*/}
<div className="min-w-[768px]">
<div className={loneWidget ? undefined : 'min-w-[768px]'}>
<DashboardRenderer
schema={draft as any}
schema={renderedSchema as any}
dataSource={adapter as any}
designMode={designMode}
selectedWidgetId={selectedWidgetId}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof import('../metadata-admin/useMetadata')>();
return {
...mod,
useMetadataClient: () => mockClient,
useMetadataTypes: () => ({ entries: [] }),
};
});

vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
return { ...mod, fetchPackages: vi.fn(async () => []) };
});

vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
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(
<MemoryRouter initialEntries={['/studio/com.acme.app/interfaces']}>
<InterfacesPillar packageId="com.acme.app" />
</MemoryRouter>,
);
}

/** 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<HTMLElement>('[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/);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2003,6 +2003,19 @@ export function InterfacesPillar({
// cedes the right side to the chat dock), so they are built once here. The
// extraction is presentation-neutral: the classic branch composes exactly
// the pre-P3c tree.
// objectui#8219 — true exactly when the canvas below renders its `Preview`
// branch (same guards, same order). A registered preview brings its own
// frame (PreviewShell), so the wrapper then draws none: one frame, and the
// wrapper's border and padding go back to the preview. Every other canvas
// state (no app, nothing picked, loading, the studio-canvas records grid,
// no designer) has no shell of its own and keeps the wrapper's card.
const canvasHostsPreviewShell =
!(appStatus === 'missing' && !error) &&
!!current &&
!loading &&
!StudioCanvas &&
!isSourcePage &&
!!Preview;
const canvasEl = (
<main className="flex min-w-0 flex-1 flex-col overflow-auto bg-muted/30 p-4">
<div className="mb-3 flex shrink-0 items-center gap-2">
Expand Down Expand Up @@ -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 ? (
Expand Down
Loading