Skip to content

Commit 6e9c8d2

Browse files
fix(types,app-shell,plugin-dashboard,plugin-designer): the widget width / height editors write a whole four-number layout (objectui#11388) (#11398)
Fixes #11388 Clause-②: yes The three dashboard widget width / height editors now write a whole four-number `layout` through one shared helper, and the casts past `DashboardWidgetSchema['layout']` are gone, so the compiler judges each write. ## What changed - **`@object-ui/types`** gains two exports on its main entry, in a new zod-free module `dashboard-widget-layout.ts`: - `defaultWidgetPlacement(index)`: the box the dashboard grid auto-places a widget with no `layout` in. It is the fallback the spec states in the doc comment of its widget `layout` member: `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 comes from the edit, else from the stored `layout`, else from the placement. - **The three writers** all call it, with `defaultWidgetPlacement(index)` as the placement: - app-shell, Studio's `DashboardWidgetInspector` (width and height inputs); - plugin-dashboard, `DashboardWithConfig`'s live writer (`handleWidgetFieldChange`, for `layoutW` / `layoutH`); - plugin-designer, `DashboardEditor`'s `WidgetPropertyPanel`, which now takes the widget's `index`. - **`DashboardGridLayout`**'s `buildDefaultLayouts` places a widget with no `layout` through the same `defaultWidgetPlacement`. Where widgets land does not change. The grid's default is now stated once, and the editors seed from that same statement instead of a copy. - **What the inputs show.** Each width / height input, and `DashboardWithConfig`'s two sliders, shows the same completed box the writer stores. For a widget with no `layout` that box is 3 × 4, where the inputs used to show 1 × 1. Without this, editing the width would store `h: 4` while the height input still showed 1. In `DashboardWithConfig` it would be worse: the slider seed also feeds the save payload, so the live schema would hold `h: 4` and the saved `layoutH` would be 1. - Docs: a "Writing a widget's `layout`" section in the `@object-ui/types` README (its snippet is judged by `check:readme-exports`), and one paragraph each in the plugin-dashboard README and `content/docs/plugins/plugin-dashboard.mdx`. Changeset `.changeset/11388-widget-layout-complete.md`: types minor, app-shell, plugin-dashboard and plugin-designer patch. ## Where the helper lives, and the export it adds I checked each package's `package.json`. The production `@object-ui/*` deps that all three writers share are `components`, `core`, `i18n`, `react` and `types`. plugin-designer does not depend on plugin-dashboard. `@object-ui/types` is the lowest of these (core depends on it). It already holds the type the helper returns (`DashboardWidgetLayout`), and it already exports zod-free runtime helpers from its main entry, so no new dependency edge is added. Because `@object-ui/types` is published, this is **a new export from a published package's entry point**. Under the claim's own condition, its `Clause-②: no` goes back to the seat to re-judge, so this body carries no `Clause-②` line. The new module stays clear of `packages/types/src/zod/objectql.zod.ts` (objectui#11276). The only other `types` file touched is one export line in `src/index.ts`, which that branch does not touch. ## What each writer can actually see No writer can see a react-grid-layout placement: - Studio's preview and `DashboardWithConfig` both render `DashboardRenderer`, which places widgets by span and array order and computes no `x` / `y`. - `DashboardEditor`'s own grid places widgets by array order. So all three seed from `defaultWidgetPlacement(index)`. That is exactly where `DashboardGridLayout` puts a widget with no `layout`. Each writer says this in a comment at the call. ## Measurements - **Premise** (`@objectstack/spec` 17.5.0, installed): `layout: { w: 6 }` → REFUSE, `invalid_type` at `layout.x`, `layout.y`, `layout.h`. `{ h: 3 }` → refused at `x`, `y`, `w`. Four numbers → ACCEPT. No box → ACCEPT. - **Red through each real writer.** The three writer files were checked out from base `5ad9f5dc` with the new pins kept, all inside an `EXIT INT TERM` trap. Result: `Tests 8 failed | 3 passed`. The three that pass are the controls, which hold on both trees. The stored boxes were `{ w: 6 }` (Studio), `{ h: 2 }`, and `{ w: 2 }` (the `DashboardWithConfig` slider, seeded at 1). The inputs showed `'1'`. Restored with `git checkout HEAD`, then confirmed: `git diff HEAD` empty and all three blob hashes equal HEAD. - **Green:** the four pin files, `Test Files 4 passed (4)`, `Tests 18 passed (18)`. - **Reverse type check.** I put the cast-free spread back into `DashboardEditor` (`{ ...widget.layout, w: … }`) and ran `tsc --noEmit`: exit 2, `TS2322: Type '{ w: number; x?: number | undefined; y?: number | undefined; h?: number | undefined; }' is not assignable to type 'DashboardWidgetLayout'`. So the cast was what hid the write. Restored and checked by hash. - **Browser, through the real Studio inspector.** Console preview gallery (`?only=dashboard`), Chromium, no backend. I clicked the `kpi_revenue` widget (it has no `layout`), filled Width = 6, and read the stored draft: - this branch: inputs `3` / `4` before the edit; stored `layout: { x: 0, y: 0, w: 6, h: 4 }`; the renderer drew `gridColumn: span 6`, `gridRow: span 4`; parsing with the spec gives no `layout.*` issue, and the box parses on a spec-valid widget. - unfixed control (base inspector, same driver): inputs `1` / `1`; stored `layout: { w: 6 }`; the renderer drew `gridRow: span undefined`; the spec refuses with `invalid_type` at `layout.x`, `layout.y`, `layout.h`. - **NOT MEASURED: the server leg** (Studio save → reload → validate against a backend). The sibling `objectstack` checkout in this container has no install and no build, and standing up its dev server was outside this run's foreground and shared-box budget. The browser legs above measure the same stored draft up to the point where it would be sent. ## Gates Toolchain: Node v22.22.2 from `/opt/nvm` via `PATH`. A plain `pnpm install` passed engine-strict, with no override. All gates below were read on head `a831be79` (main `d59f11c0` merged in). - **Build:** `turbo run build --filter='@object-ui/app-shell^...' --concurrency=2`, 28/28 tasks. This closure contains types, plugin-dashboard and plugin-designer. I used turbo instead of `pnpm --filter` so a capped run could resume from cache. The other unbuilt packages were then built so `check:readme-exports` could judge every README. - **`type-check`** for types, plugin-dashboard, plugin-designer and app-shell: all `Done`, exit 0. Each package's `tsconfig.test.json` lists its new pin (`--listFilesOnly`). The consumers read the rebuilt `@object-ui/types` dist that carries both exports. - **vitest:** - `packages/plugin-dashboard/`, `packages/plugin-designer/`, and the outside suites that import a touched module: `Test Files 196 passed (196)`, `Tests 1733 passed | 6 skipped`. The outside suites are schema-catalog `dashboard-root-title-retired-7624`, components `inline-locale-label-read-sites`, two plugin-charts dashboard suites, and sdui-parser `dashboard-widget-options-census`. - types `dashboard-*` plus the export-surface and parity pins, and app-shell's `Dashboard*` inspector / preview suites plus every inspector suite that imports `DashboardWidgetInspector`: `Test Files 31 passed (31)`, `Tests 486 passed (486)`. - A first attempt at the whole `packages/types/` plus all of app-shell `metadata-admin/` hit the 530 s cap (exit 124). It measured nothing and was narrowed to the set above. CI runs the full set. - **Gates, exit 0 each:** `check:new-line-citations` (`0 new citation(s)`), `check:control-bytes`, `check-changeset-presence` (`11 source file(s) of 4 releas…`, 1 changeset added), `changeset:check`, `check-changeset-overwrite`, `check-changeset-claims`, `check:readme-exports` (`561 real, 0 wrong-path, 0 fabricated`, 0 unbuilt), `check-vi-mock-specifiers` / `-inherit` / `-override-shape`, `check-test-path-roots`, `check:doc-examples`, `check-doc-snippet-types`, `check-doc-links`, `check-doc-fence-languages`, `check:unreferenced-sources`, `check:self-import`, `check:esm-specifiers`, `check:phantom-deps`, `check-type-check-coverage`, `check:eager-closure`. - Before the merge, these were also exit 0: `check:doc-example-ids`, `check:doc-example-readers`, `check-doc-component-types`, `check-doc-expression-carriage`, `check-lint-coverage`, `check:published-dist`, `check:side-effects-array`, `check:entry-guard`, `check:spec-symbols`. - `check:node-esm-load` exited 1 on provenance: it refused two entries the shared turbo cache replayed from other worktrees (`@object-ui/auth`, `@object-ui/react-runtime`). Neither is touched here, and both are NOT MEASURED. Its load leg imported 32 of 39 entries, including `@object-ui/types` and `@object-ui/plugin-designer`. app-shell and plugin-dashboard are unbundled-Node unsupported by ruling. - **Lint, narrowed to the changed files.** `eslint --format json` on the 11 changed `.ts` / `.tsx` files: 11 results, 0 errors, 31 warnings. The warnings are the same per-file counts base `5ad9f5dc` gives the five modified files. The two new files add none. - Population: each package's `lint` is `eslint .` under the root `eslint.config.js`. - Why the narrowing cannot change another file's verdict: that config enables no type-aware linting (no `parserOptions.project` / `projectService`), and no rule in `eslint-rules/` reads the filesystem. The full `pnpm lint` is CI's. - Governed surface: `check-governed-queue-guard --test` over the 15 changed paths → NOT GOVERNED. ## Acceptance notes - **Two renderers, two defaults.** In the positioned grid, `DashboardRenderer` gives a widget with no `layout` a span of its own: 3 × 2 for a metric, 6 × 4 otherwise. That is not the spec-stated auto-placement (3 × 4) that `DashboardGridLayout` uses and that the editors now seed from, though its comment says it matches. One visible effect in Studio: after a width edit, a metric that had no `layout` gets `h: 4`, where the preview had drawn it 2 rows tall. Not changed here. Carrier: none. - **`DashboardWithConfig`'s save payload** is the documented flattened `layoutW` / `layoutH` with no `x` / `y`, and the host un-flattens it. Neither objectui nor objectstack has a host for it. The shape is unchanged. For a widget with no `layout` that the author did not resize, the values are now 3 / 4 where they were 1 / 1. Carrier: none. - `DashboardEditor`'s `addWidget` seeds its own complete box (`x: 0`, `y` = index, `w` = `columns ?? 2`, `h: 1`). It is spec-valid and not changed here. - One existing pin was touched. The map-title round trip in `DashboardWidgetInspector.test.tsx` used "height = 4" as its unrelated edit. The input now shows 4 for a widget with no `layout`, so that edit fired no change. It now edits 2, with a comment saying why. Session: `https://claude.ai/code/session_01JG2jy8a9su7ia4Hx7zxv42` (seat `domain:ui#2`, claim on the card). --- _Generated by [Claude Code](https://claude.ai/code/session_01JG2jy8a9su7ia4Hx7zxv42)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d59f11c commit 6e9c8d2

15 files changed

Lines changed: 672 additions & 30 deletions
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
'@object-ui/types': minor
3+
'@object-ui/app-shell': patch
4+
'@object-ui/plugin-dashboard': patch
5+
'@object-ui/plugin-designer': patch
6+
---
7+
8+
fix: the widget width / height editors write a whole four-number `layout` (objectui#11388)
9+
10+
The three dashboard widget width / height editors (Studio's widget inspector in
11+
`@object-ui/app-shell`, `DashboardWithConfig` in `@object-ui/plugin-dashboard` and
12+
`DashboardEditor` in `@object-ui/plugin-designer`) each spread the one edited
13+
dimension onto the widget's possibly-absent `layout` and cast the result past the
14+
declared four-number type. On a widget with no `layout`, which is how Studio adds
15+
one, they stored `{ w }` or `{ h }`, and `@objectstack/spec`'s widget `layout`
16+
refuses that box at `x`, `y` and the other dimension.
17+
18+
`@object-ui/types` gains two exports on its main entry:
19+
20+
- `defaultWidgetPlacement(index)`: the box the dashboard grid auto-places the
21+
widget at that position of `widgets[]` in when it has no `layout`, the fallback
22+
the spec's widget `layout` states (`x: (i % 4) * 3`, `y: Math.floor(i / 4) * 4`,
23+
`w: 3`, `h: 4`).
24+
- `completeWidgetLayout(layout, patch, placement)`: the whole box after an edit,
25+
each coordinate taken from the edit, else from the stored `layout`, else from
26+
the placement.
27+
28+
All three editors now write through `completeWidgetLayout`, seeding from
29+
`defaultWidgetPlacement` for the widget's index, and the casts are gone, so the
30+
compiler judges each write. A widget that has a `layout` keeps its `x` and `y`.
31+
The width and height inputs (and `DashboardWithConfig`'s sliders) show the same
32+
completed box, so a widget with no `layout` now shows the auto-placed 3 × 4 where
33+
it showed 1 × 1, and the dimension an edit leaves alone is the one on screen.
34+
`DashboardGridLayout` places a widget with no `layout` through
35+
`defaultWidgetPlacement`, with no change to where it lands.
36+
37+
`DashboardWithConfig`'s `onWidgetSave` payload keeps its flattened `layoutW` /
38+
`layoutH` shape. For a widget with no `layout` that the author did not resize,
39+
the two values it carries are now the auto-placed 3 and 4, not 1 and 1.

‎content/docs/plugins/plugin-dashboard.mdx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,13 @@ off any `widgets[]` entry, the strict authoring face accepts it on a component
422422
node, and a malformed one (not four numbers, or a key besides `x`, `y`, `w` and
423423
`h`) is refused on either arm.
424424

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

427434
`type: 'table'` widgets bound to an `objectName` infer the renderer for
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#11388 — the Studio widget inspector's width / height inputs write a
5+
* whole four-number `layout`.
6+
*
7+
* Studio adds a widget with NO `layout` (the grid auto-places it). The inputs
8+
* edit one dimension each, and used to spread that one number onto the absent
9+
* box, storing `{ w }` or `{ h }`, which `@objectstack/spec`'s
10+
* `DashboardWidgetSchema` refuses. Each case parses the widget the inspector
11+
* hands `onPatch` with the spec's own schema.
12+
*/
13+
14+
import * as React from 'react';
15+
import { describe, it, expect, vi, afterEach } from 'vitest';
16+
import { render, cleanup, fireEvent } from '@testing-library/react';
17+
import { DashboardWidgetSchema as SpecDashboardWidgetSchema } from '@objectstack/spec/ui';
18+
import type { DashboardWidgetSchema } from '@object-ui/types';
19+
20+
// Network-free catalog, as in `DashboardWidgetInspector.test.tsx`.
21+
vi.mock('../previews/useDatasetCatalog', () => ({
22+
useDatasetCatalog: () => ({
23+
datasets: [{ name: 'sales_pipeline', label: 'Sales Pipeline', dimensions: [], measures: [] }],
24+
loading: false,
25+
error: null,
26+
}),
27+
useDatasetSemantics: () => ({
28+
dimensions: [{ name: 'stage', type: 'string' }],
29+
measures: [{ name: 'revenue', aggregate: 'sum' }],
30+
loading: false,
31+
error: null,
32+
}),
33+
}));
34+
35+
import { DashboardWidgetInspector } from './DashboardWidgetInspector';
36+
37+
afterEach(cleanup);
38+
39+
// Spec-valid apart from `layout`, so a refusal below is the layout's.
40+
const bound = (id: string, extra: Record<string, unknown> = {}) => ({
41+
id,
42+
type: 'bar',
43+
title: id,
44+
dataset: 'sales_pipeline',
45+
dimensions: ['stage'],
46+
values: ['revenue'],
47+
...extra,
48+
});
49+
50+
function renderInspector(widgets: Record<string, unknown>[], selectedId: string) {
51+
const onPatch = vi.fn();
52+
render(
53+
<DashboardWidgetInspector
54+
type="dashboard"
55+
name="sales"
56+
locale="en-US"
57+
readOnly={false}
58+
onClearSelection={vi.fn()}
59+
onSelectionChange={vi.fn()}
60+
draft={{ widgets }}
61+
selection={{ kind: 'widget', id: selectedId }}
62+
onPatch={onPatch}
63+
/>,
64+
);
65+
return onPatch;
66+
}
67+
68+
/** The selected widget from the last `onPatch({ widgets })`. */
69+
function patchedWidget(onPatch: ReturnType<typeof vi.fn>, id: string): DashboardWidgetSchema {
70+
const calls = onPatch.mock.calls;
71+
const { widgets } = calls[calls.length - 1][0] as { widgets: DashboardWidgetSchema[] };
72+
return widgets.find((w) => w.id === id)!;
73+
}
74+
75+
describe('DashboardWidgetInspector — width / height write a whole layout (objectui#11388)', () => {
76+
it('editing the width of a widget with no layout stores four numbers the spec parses', () => {
77+
const onPatch = renderInspector([bound('w1')], 'w1');
78+
fireEvent.change(document.getElementById('widget-w')!, { target: { value: '6' } });
79+
80+
const widget = patchedWidget(onPatch, 'w1');
81+
expect(widget.layout).toEqual({ x: 0, y: 0, w: 6, h: 4 });
82+
const parsed = SpecDashboardWidgetSchema.safeParse(widget);
83+
expect(parsed.success, parsed.success ? '' : JSON.stringify(parsed.error.issues)).toBe(true);
84+
});
85+
86+
it('editing the height seeds x, y and the width from the grid auto-placement of its index', () => {
87+
// The fifth widget (index 4) is auto-placed at the start of the second row.
88+
const widgets = ['a', 'b', 'c', 'd', 'w5'].map((id) => bound(id));
89+
const onPatch = renderInspector(widgets, 'w5');
90+
fireEvent.change(document.getElementById('widget-h')!, { target: { value: '2' } });
91+
92+
const widget = patchedWidget(onPatch, 'w5');
93+
expect(widget.layout).toEqual({ x: 0, y: 4, w: 3, h: 2 });
94+
expect(SpecDashboardWidgetSchema.safeParse(widget).success).toBe(true);
95+
});
96+
97+
it('control: a widget that has a layout keeps its x, y and the other dimension', () => {
98+
const onPatch = renderInspector([bound('w1', { layout: { x: 6, y: 2, w: 3, h: 5 } })], 'w1');
99+
fireEvent.change(document.getElementById('widget-w')!, { target: { value: '4' } });
100+
expect(patchedWidget(onPatch, 'w1').layout).toEqual({ x: 6, y: 2, w: 4, h: 5 });
101+
});
102+
103+
it('the inputs show the box an edit completes, so the dimension left alone is the one on screen', () => {
104+
renderInspector([bound('w1')], 'w1');
105+
expect((document.getElementById('widget-w') as HTMLInputElement).value).toBe('3');
106+
expect((document.getElementById('widget-h') as HTMLInputElement).value).toBe('4');
107+
});
108+
});

‎packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.test.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,9 @@ describe('DashboardWidgetInspector — map-valued title write-back (#5428)', ()
385385
// rewrite the title at all — not even into an equal-but-rebuilt object.
386386
const onPatch = vi.fn();
387387
renderWidget({ title: MAP_TITLE, dataset: 'sales_pipeline' }, { onPatch });
388-
fireEvent.change(screen.getByLabelText('Height'), { target: { value: '4' } });
388+
// Any height but the auto-placed 4 the input now shows for a widget with no
389+
// `layout` (objectui#11388): re-entering the shown value fires no change.
390+
fireEvent.change(screen.getByLabelText('Height'), { target: { value: '2' } });
389391
expect(patchedTitle(onPatch)).toBe(MAP_TITLE);
390392
});
391393
});

‎packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx‎

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
SelectValue,
3131
} from '@object-ui/components';
3232
import type { DashboardWidgetSchema, DashboardWidgetTypeName } from '@object-ui/types';
33+
import { completeWidgetLayout, defaultWidgetPlacement } from '@object-ui/types';
3334
import { resolveDashboardFilterDefs, type DashboardFilterDef, type ComponentMeta } from '@object-ui/core';
3435
import type { MetadataInspectorProps } from '../inspector-registry.js';
3536
import { t, tFormat } from '../i18n.js';
@@ -196,6 +197,10 @@ export function DashboardWidgetInspector({
196197
}
197198

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

200205
function patchWidget(updates: Partial<DashboardWidgetSchema>) {
201206
const widgets = [...widgetsAll];
@@ -488,19 +493,27 @@ export function DashboardWidgetInspector({
488493
/>
489494
</Field>
490495

496+
{/*
497+
Width and height each edit ONE dimension of the spec's four-number
498+
`layout`, so both go through `completeWidgetLayout` (objectui#11388):
499+
on a widget with no `layout` the untouched coordinates come from the
500+
grid's auto-placement for this widget's index, and the stored box is
501+
whole. Spreading the one number onto an absent box stored `{ w }`, which
502+
the spec refuses. Both inputs SHOW the same completed box, so the
503+
dimension an edit leaves alone is the one on screen. The preview grid
504+
computes no `x` / `y` this inspector can read, so it seeds from
505+
`defaultWidgetPlacement`.
506+
*/}
491507
<div className="grid grid-cols-2 gap-3">
492508
<Field id="widget-w" label={t('engine.inspector.widget.width', locale)}>
493509
<Input
494510
id="widget-w"
495511
type="number"
496512
min={1}
497-
value={widget.layout?.w ?? 1}
513+
value={layout.w}
498514
onChange={(e) =>
499515
patchWidget({
500-
layout: {
501-
...(widget.layout ?? {}),
502-
w: Number(e.target.value) || 1,
503-
} as DashboardWidgetSchema['layout'],
516+
layout: completeWidgetLayout(widget.layout, { w: Number(e.target.value) || 1 }, placement),
504517
})
505518
}
506519
disabled={readOnly}
@@ -511,13 +524,10 @@ export function DashboardWidgetInspector({
511524
id="widget-h"
512525
type="number"
513526
min={1}
514-
value={widget.layout?.h ?? 1}
527+
value={layout.h}
515528
onChange={(e) =>
516529
patchWidget({
517-
layout: {
518-
...(widget.layout ?? {}),
519-
h: Number(e.target.value) || 1,
520-
} as DashboardWidgetSchema['layout'],
530+
layout: completeWidgetLayout(widget.layout, { h: Number(e.target.value) || 1 }, placement),
521531
})
522532
}
523533
disabled={readOnly}

‎packages/plugin-dashboard/README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,12 @@ entry of `widgets[]`, a `metric-card` node included (see
601601
strict authoring face accepts it on a component node. A malformed one (not
602602
four numbers, or a key besides `x`, `y`, `w` and `h`) is refused on either arm.
603603

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

606612
All components support Tailwind CSS classes:

‎packages/plugin-dashboard/src/DashboardGridLayout.tsx‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Edit, GripVertical, Save, X, RefreshCw } from 'lucide-react';
66
import { SchemaRenderer, useHasDndProvider, useDnd } from '@object-ui/react';
77
import { useObjectTranslation, useObjectLabel, useSafeTranslate, pickLocalized } from '@object-ui/i18n';
88
import type { BaseSchema, DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui/types';
9+
import { completeWidgetLayout, defaultWidgetPlacement } from '@object-ui/types';
910
import { chartCategoryKey, chartConfigPresentation, chartMeasureKey } from '@object-ui/core';
1011
import { isObjectProvider, deriveStaticTableColumns, composeSeriesLabel } from './utils';
1112
import { classifyWidgetType } from './widgetDispatch';
@@ -107,14 +108,17 @@ export function mergeLayoutIntoSchema(
107108
return { ...schema, widgets };
108109
}
109110

111+
/**
112+
* The grid's box for every widget. A widget with no `layout` is auto-placed by
113+
* `defaultWidgetPlacement` — the fallback the spec's widget `layout` states,
114+
* and the one the widget width / height editors seed a new box from
115+
* (objectui#11388), so this grid and those editors place it in the same spot.
116+
*/
110117
function buildDefaultLayouts(schema: DashboardComponentSchema): { lg: RGLLayout[] } {
111118
return {
112119
lg: schema.widgets?.map((widget: DashboardWidgetSchema, index: number) => ({
113120
i: widget.id || `widget-${index}`,
114-
x: widget.layout?.x ?? (index % 4) * 3,
115-
y: widget.layout?.y ?? Math.floor(index / 4) * 4,
116-
w: widget.layout?.w ?? 3,
117-
h: widget.layout?.h ?? 4,
121+
...completeWidgetLayout(widget.layout, {}, defaultWidgetPlacement(index)),
118122
})) || [],
119123
};
120124
}

‎packages/plugin-dashboard/src/DashboardWithConfig.tsx‎

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useState, useCallback, useEffect } from 'react';
1111
import { Settings } from 'lucide-react';
1212
import { cn, Button } from '@object-ui/components';
1313
import type { DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui/types';
14+
import { completeWidgetLayout, defaultWidgetPlacement } from '@object-ui/types';
1415

1516
import { DashboardRenderer } from './DashboardRenderer';
1617
import { DashboardConfigPanel } from './DashboardConfigPanel';
@@ -104,10 +105,15 @@ export function DashboardWithConfig({
104105
// `dashboard-widget-slot-component-arm-7952.test.ts`), so the annotation is
105106
// checked by the compiler rather than asserted.
106107
const widgets: DashboardWidgetSchema[] = liveSchema.widgets;
107-
const widget = widgets.find(
108+
const index = widgets.findIndex(
108109
(w) => (w.id || w.title) === selectedWidgetId,
109110
);
110-
if (!widget) return null;
111+
if (index < 0) return null;
112+
const widget = widgets[index];
113+
// The panel's width / height sliders start from the same completed box the
114+
// live writer below stores (objectui#11388), so the dimension an edit
115+
// leaves alone is the one the slider showed.
116+
const layout = completeWidgetLayout(widget.layout, {}, defaultWidgetPlacement(index));
111117
// ADR-0021 dataset shape — the only authoring shape the panel edits.
112118
// `dataset`/`dimensions`/`values` are read through casts: the bundled
113119
// `@object-ui/types` gains them once objectui bumps `@objectstack/spec`.
@@ -127,8 +133,8 @@ export function DashboardWithConfig({
127133
// `actionUrl: widget.actionUrl ?? ''` here meant EVERY save from the
128134
// widget panel emitted `actionUrl: ''` — a parse error — even when the
129135
// author never opened the Behavior group (objectstack#7129).
130-
layoutW: widget.layout?.w ?? 1,
131-
layoutH: widget.layout?.h ?? 1,
136+
layoutW: layout.w,
137+
layoutH: layout.h,
132138
};
133139
// eslint-disable-next-line react-hooks/exhaustive-deps
134140
}, [selectedWidgetId, configVersion]);
@@ -164,13 +170,19 @@ export function DashboardWithConfig({
164170
...prev,
165171
// `DashboardWidgetSchema`, for the reason `selectedWidgetConfig`
166172
// states: `title` is a widget-arm key (objectui#11348).
167-
widgets: prev.widgets.map((w: DashboardWidgetSchema) => {
173+
//
174+
// A slider edits ONE dimension of the spec's four-number `layout`,
175+
// so it goes through `completeWidgetLayout` (objectui#11388): on a
176+
// widget with no `layout` the untouched coordinates come from the
177+
// grid's auto-placement for its index, and the box is whole.
178+
// Spreading the one number onto an absent box stored `{ w }`, which
179+
// the spec refuses. `DashboardRenderer` computes no `x` / `y` this
180+
// component can read, so it seeds from `defaultWidgetPlacement`.
181+
widgets: prev.widgets.map((w: DashboardWidgetSchema, index: number) => {
168182
if ((w.id || w.title) !== selectedWidgetId) return w;
169-
if (field === 'layoutW') {
170-
return { ...w, layout: { ...(w.layout || {}), w: value } as DashboardWidgetSchema['layout'] };
171-
}
172-
if (field === 'layoutH') {
173-
return { ...w, layout: { ...(w.layout || {}), h: value } as DashboardWidgetSchema['layout'] };
183+
if (field === 'layoutW' || field === 'layoutH') {
184+
const patch = field === 'layoutW' ? { w: value } : { h: value };
185+
return { ...w, layout: completeWidgetLayout(w.layout, patch, defaultWidgetPlacement(index)) };
174186
}
175187
return { ...w, [field]: value };
176188
}),

0 commit comments

Comments
 (0)