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
39 changes: 39 additions & 0 deletions .changeset/11388-widget-layout-complete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
'@object-ui/types': minor
'@object-ui/app-shell': patch
'@object-ui/plugin-dashboard': patch
'@object-ui/plugin-designer': patch
---

fix: the widget width / height editors write a whole four-number `layout` (objectui#11388)

The three dashboard widget width / height editors (Studio's widget inspector in
`@object-ui/app-shell`, `DashboardWithConfig` in `@object-ui/plugin-dashboard` and
`DashboardEditor` in `@object-ui/plugin-designer`) each spread the one edited
dimension onto the widget's possibly-absent `layout` and cast the result past the
declared four-number type. On a widget with no `layout`, which is how Studio adds
one, they stored `{ w }` or `{ h }`, and `@objectstack/spec`'s widget `layout`
refuses that box at `x`, `y` and the other dimension.

`@object-ui/types` gains two exports on its main entry:

- `defaultWidgetPlacement(index)`: the box the dashboard grid auto-places the
widget at that position of `widgets[]` in when it has no `layout`, the fallback
the spec's widget `layout` states (`x: (i % 4) * 3`, `y: Math.floor(i / 4) * 4`,
`w: 3`, `h: 4`).
- `completeWidgetLayout(layout, patch, placement)`: the whole box after an edit,
each coordinate taken from the edit, else from the stored `layout`, else from
the placement.

All three editors now write through `completeWidgetLayout`, seeding from
`defaultWidgetPlacement` for the widget's index, and the casts are gone, so the
compiler judges each write. A widget that has a `layout` keeps its `x` and `y`.
The width and height inputs (and `DashboardWithConfig`'s sliders) show the same
completed box, so a widget with no `layout` now shows the auto-placed 3 × 4 where
it showed 1 × 1, and the dimension an edit leaves alone is the one on screen.
`DashboardGridLayout` places a widget with no `layout` through
`defaultWidgetPlacement`, with no change to where it lands.

`DashboardWithConfig`'s `onWidgetSave` payload keeps its flattened `layoutW` /
`layoutH` shape. For a widget with no `layout` that the author did not resize,
the two values it carries are now the auto-placed 3 and 4, not 1 and 1.
7 changes: 7 additions & 0 deletions content/docs/plugins/plugin-dashboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,13 @@ off any `widgets[]` entry, the strict authoring face accepts it on a component
node, and a malformed one (not four numbers, or a key besides `x`, `y`, `w` and
`h`) is refused on either arm.

A widget with no `layout` is auto-placed by the grid. The width and height
editors (Studio's widget inspector, `DashboardWithConfig` and
`DashboardEditor`) write the whole box: the coordinates an edit leaves alone
come from where the grid auto-places that widget, through `completeWidgetLayout`
and `defaultWidgetPlacement` from `@object-ui/types`. So changing one dimension
never stores a `{ w }` or `{ h }` box, which the spec refuses.

## Type-aware list/table widget cells

`type: 'table'` widgets bound to an `objectName` infer the renderer for
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#11388 — the Studio widget inspector's width / height inputs write a
* whole four-number `layout`.
*
* Studio adds a widget with NO `layout` (the grid auto-places it). The inputs
* edit one dimension each, and used to spread that one number onto the absent
* box, storing `{ w }` or `{ h }`, which `@objectstack/spec`'s
* `DashboardWidgetSchema` refuses. Each case parses the widget the inspector
* hands `onPatch` with the spec's own schema.
*/

import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, fireEvent } from '@testing-library/react';
import { DashboardWidgetSchema as SpecDashboardWidgetSchema } from '@objectstack/spec/ui';
import type { DashboardWidgetSchema } from '@object-ui/types';

// Network-free catalog, as in `DashboardWidgetInspector.test.tsx`.
vi.mock('../previews/useDatasetCatalog', () => ({
useDatasetCatalog: () => ({
datasets: [{ name: 'sales_pipeline', label: 'Sales Pipeline', dimensions: [], measures: [] }],
loading: false,
error: null,
}),
useDatasetSemantics: () => ({
dimensions: [{ name: 'stage', type: 'string' }],
measures: [{ name: 'revenue', aggregate: 'sum' }],
loading: false,
error: null,
}),
}));

import { DashboardWidgetInspector } from './DashboardWidgetInspector';

afterEach(cleanup);

// Spec-valid apart from `layout`, so a refusal below is the layout's.
const bound = (id: string, extra: Record<string, unknown> = {}) => ({
id,
type: 'bar',
title: id,
dataset: 'sales_pipeline',
dimensions: ['stage'],
values: ['revenue'],
...extra,
});

function renderInspector(widgets: Record<string, unknown>[], selectedId: string) {
const onPatch = vi.fn();
render(
<DashboardWidgetInspector
type="dashboard"
name="sales"
locale="en-US"
readOnly={false}
onClearSelection={vi.fn()}
onSelectionChange={vi.fn()}
draft={{ widgets }}
selection={{ kind: 'widget', id: selectedId }}
onPatch={onPatch}
/>,
);
return onPatch;
}

/** The selected widget from the last `onPatch({ widgets })`. */
function patchedWidget(onPatch: ReturnType<typeof vi.fn>, id: string): DashboardWidgetSchema {
const calls = onPatch.mock.calls;
const { widgets } = calls[calls.length - 1][0] as { widgets: DashboardWidgetSchema[] };
return widgets.find((w) => w.id === id)!;
}

describe('DashboardWidgetInspector — width / height write a whole layout (objectui#11388)', () => {
it('editing the width of a widget with no layout stores four numbers the spec parses', () => {
const onPatch = renderInspector([bound('w1')], 'w1');
fireEvent.change(document.getElementById('widget-w')!, { target: { value: '6' } });

const widget = patchedWidget(onPatch, 'w1');
expect(widget.layout).toEqual({ x: 0, y: 0, w: 6, h: 4 });
const parsed = SpecDashboardWidgetSchema.safeParse(widget);
expect(parsed.success, parsed.success ? '' : JSON.stringify(parsed.error.issues)).toBe(true);
});

it('editing the height seeds x, y and the width from the grid auto-placement of its index', () => {
// The fifth widget (index 4) is auto-placed at the start of the second row.
const widgets = ['a', 'b', 'c', 'd', 'w5'].map((id) => bound(id));
const onPatch = renderInspector(widgets, 'w5');
fireEvent.change(document.getElementById('widget-h')!, { target: { value: '2' } });

const widget = patchedWidget(onPatch, 'w5');
expect(widget.layout).toEqual({ x: 0, y: 4, w: 3, h: 2 });
expect(SpecDashboardWidgetSchema.safeParse(widget).success).toBe(true);
});

it('control: a widget that has a layout keeps its x, y and the other dimension', () => {
const onPatch = renderInspector([bound('w1', { layout: { x: 6, y: 2, w: 3, h: 5 } })], 'w1');
fireEvent.change(document.getElementById('widget-w')!, { target: { value: '4' } });
expect(patchedWidget(onPatch, 'w1').layout).toEqual({ x: 6, y: 2, w: 4, h: 5 });
});

it('the inputs show the box an edit completes, so the dimension left alone is the one on screen', () => {
renderInspector([bound('w1')], 'w1');
expect((document.getElementById('widget-w') as HTMLInputElement).value).toBe('3');
expect((document.getElementById('widget-h') as HTMLInputElement).value).toBe('4');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,9 @@ describe('DashboardWidgetInspector — map-valued title write-back (#5428)', ()
// rewrite the title at all — not even into an equal-but-rebuilt object.
const onPatch = vi.fn();
renderWidget({ title: MAP_TITLE, dataset: 'sales_pipeline' }, { onPatch });
fireEvent.change(screen.getByLabelText('Height'), { target: { value: '4' } });
// Any height but the auto-placed 4 the input now shows for a widget with no
// `layout` (objectui#11388): re-entering the shown value fires no change.
fireEvent.change(screen.getByLabelText('Height'), { target: { value: '2' } });
expect(patchedTitle(onPatch)).toBe(MAP_TITLE);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
SelectValue,
} from '@object-ui/components';
import type { DashboardWidgetSchema, DashboardWidgetTypeName } from '@object-ui/types';
import { completeWidgetLayout, defaultWidgetPlacement } from '@object-ui/types';
import { resolveDashboardFilterDefs, type DashboardFilterDef, type ComponentMeta } from '@object-ui/core';
import type { MetadataInspectorProps } from '../inspector-registry.js';
import { t, tFormat } from '../i18n.js';
Expand Down Expand Up @@ -196,6 +197,10 @@ export function DashboardWidgetInspector({
}

const { widget, index } = hit;
// The box the grid auto-places this widget in, and the widget's layout
// completed against it — what the width / height inputs show and write.
const placement = defaultWidgetPlacement(index);
const layout = completeWidgetLayout(widget.layout, {}, placement);

function patchWidget(updates: Partial<DashboardWidgetSchema>) {
const widgets = [...widgetsAll];
Expand Down Expand Up @@ -488,19 +493,27 @@ export function DashboardWidgetInspector({
/>
</Field>

{/*
Width and height each edit ONE dimension of the spec's four-number
`layout`, so both go through `completeWidgetLayout` (objectui#11388):
on a widget with no `layout` the untouched coordinates come from the
grid's auto-placement for this widget's index, and the stored box is
whole. Spreading the one number onto an absent box stored `{ w }`, which
the spec refuses. Both inputs SHOW the same completed box, so the
dimension an edit leaves alone is the one on screen. The preview grid
computes no `x` / `y` this inspector can read, so it seeds from
`defaultWidgetPlacement`.
*/}
<div className="grid grid-cols-2 gap-3">
<Field id="widget-w" label={t('engine.inspector.widget.width', locale)}>
<Input
id="widget-w"
type="number"
min={1}
value={widget.layout?.w ?? 1}
value={layout.w}
onChange={(e) =>
patchWidget({
layout: {
...(widget.layout ?? {}),
w: Number(e.target.value) || 1,
} as DashboardWidgetSchema['layout'],
layout: completeWidgetLayout(widget.layout, { w: Number(e.target.value) || 1 }, placement),
})
}
disabled={readOnly}
Expand All @@ -511,13 +524,10 @@ export function DashboardWidgetInspector({
id="widget-h"
type="number"
min={1}
value={widget.layout?.h ?? 1}
value={layout.h}
onChange={(e) =>
patchWidget({
layout: {
...(widget.layout ?? {}),
h: Number(e.target.value) || 1,
} as DashboardWidgetSchema['layout'],
layout: completeWidgetLayout(widget.layout, { h: Number(e.target.value) || 1 }, placement),
})
}
disabled={readOnly}
Expand Down
6 changes: 6 additions & 0 deletions packages/plugin-dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,12 @@ entry of `widgets[]`, a `metric-card` node included (see
strict authoring face accepts it on a component node. A malformed one (not
four numbers, or a key besides `x`, `y`, `w` and `h`) is refused on either arm.

A widget with no `layout` is auto-placed by the grid. `DashboardWithConfig`'s
width and height sliders write the whole box: the dimension a slider does not
edit, and `x` and `y`, come from where the grid auto-places that widget
(`completeWidgetLayout` and `defaultWidgetPlacement` from `@object-ui/types`,
objectui#11388), so a one-dimension edit never stores a box the spec refuses.

## Customization

All components support Tailwind CSS classes:
Expand Down
12 changes: 8 additions & 4 deletions packages/plugin-dashboard/src/DashboardGridLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Edit, GripVertical, Save, X, RefreshCw } from 'lucide-react';
import { SchemaRenderer, useHasDndProvider, useDnd } from '@object-ui/react';
import { useObjectTranslation, useObjectLabel, useSafeTranslate, pickLocalized } from '@object-ui/i18n';
import type { BaseSchema, DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui/types';
import { completeWidgetLayout, defaultWidgetPlacement } from '@object-ui/types';
import { chartCategoryKey, chartConfigPresentation, chartMeasureKey } from '@object-ui/core';
import { isObjectProvider, deriveStaticTableColumns, composeSeriesLabel } from './utils';
import { classifyWidgetType } from './widgetDispatch';
Expand Down Expand Up @@ -107,14 +108,17 @@ export function mergeLayoutIntoSchema(
return { ...schema, widgets };
}

/**
* The grid's box for every widget. A widget with no `layout` is auto-placed by
* `defaultWidgetPlacement` — the fallback the spec's widget `layout` states,
* and the one the widget width / height editors seed a new box from
* (objectui#11388), so this grid and those editors place it in the same spot.
*/
function buildDefaultLayouts(schema: DashboardComponentSchema): { lg: RGLLayout[] } {
return {
lg: schema.widgets?.map((widget: DashboardWidgetSchema, index: number) => ({
i: widget.id || `widget-${index}`,
x: widget.layout?.x ?? (index % 4) * 3,
y: widget.layout?.y ?? Math.floor(index / 4) * 4,
w: widget.layout?.w ?? 3,
h: widget.layout?.h ?? 4,
...completeWidgetLayout(widget.layout, {}, defaultWidgetPlacement(index)),
})) || [],
};
}
Expand Down
32 changes: 22 additions & 10 deletions packages/plugin-dashboard/src/DashboardWithConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useState, useCallback, useEffect } from 'react';
import { Settings } from 'lucide-react';
import { cn, Button } from '@object-ui/components';
import type { DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui/types';
import { completeWidgetLayout, defaultWidgetPlacement } from '@object-ui/types';

import { DashboardRenderer } from './DashboardRenderer';
import { DashboardConfigPanel } from './DashboardConfigPanel';
Expand Down Expand Up @@ -104,10 +105,15 @@ export function DashboardWithConfig({
// `dashboard-widget-slot-component-arm-7952.test.ts`), so the annotation is
// checked by the compiler rather than asserted.
const widgets: DashboardWidgetSchema[] = liveSchema.widgets;
const widget = widgets.find(
const index = widgets.findIndex(
(w) => (w.id || w.title) === selectedWidgetId,
);
if (!widget) return null;
if (index < 0) return null;
const widget = widgets[index];
// The panel's width / height sliders start from the same completed box the
// live writer below stores (objectui#11388), so the dimension an edit
// leaves alone is the one the slider showed.
const layout = completeWidgetLayout(widget.layout, {}, defaultWidgetPlacement(index));
// ADR-0021 dataset shape — the only authoring shape the panel edits.
// `dataset`/`dimensions`/`values` are read through casts: the bundled
// `@object-ui/types` gains them once objectui bumps `@objectstack/spec`.
Expand All @@ -127,8 +133,8 @@ export function DashboardWithConfig({
// `actionUrl: widget.actionUrl ?? ''` here meant EVERY save from the
// widget panel emitted `actionUrl: ''` — a parse error — even when the
// author never opened the Behavior group (objectstack#7129).
layoutW: widget.layout?.w ?? 1,
layoutH: widget.layout?.h ?? 1,
layoutW: layout.w,
layoutH: layout.h,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedWidgetId, configVersion]);
Expand Down Expand Up @@ -164,13 +170,19 @@ export function DashboardWithConfig({
...prev,
// `DashboardWidgetSchema`, for the reason `selectedWidgetConfig`
// states: `title` is a widget-arm key (objectui#11348).
widgets: prev.widgets.map((w: DashboardWidgetSchema) => {
//
// A slider edits ONE dimension of the spec's four-number `layout`,
// so it goes through `completeWidgetLayout` (objectui#11388): on a
// widget with no `layout` the untouched coordinates come from the
// grid's auto-placement for its index, and the box is whole.
// Spreading the one number onto an absent box stored `{ w }`, which
// the spec refuses. `DashboardRenderer` computes no `x` / `y` this
// component can read, so it seeds from `defaultWidgetPlacement`.
widgets: prev.widgets.map((w: DashboardWidgetSchema, index: number) => {
if ((w.id || w.title) !== selectedWidgetId) return w;
if (field === 'layoutW') {
return { ...w, layout: { ...(w.layout || {}), w: value } as DashboardWidgetSchema['layout'] };
}
if (field === 'layoutH') {
return { ...w, layout: { ...(w.layout || {}), h: value } as DashboardWidgetSchema['layout'] };
if (field === 'layoutW' || field === 'layoutH') {
const patch = field === 'layoutW' ? { w: value } : { h: value };
return { ...w, layout: completeWidgetLayout(w.layout, patch, defaultWidgetPlacement(index)) };
}
return { ...w, [field]: value };
}),
Expand Down
Loading
Loading