From 921e56cc64474655578cccc453e0582b1b964cd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 06:05:33 +0000 Subject: [PATCH 1/3] fix(plugin-dashboard): the static-data `chart` producers build a private hand-off type, `ChartSchema` plus `colors` and `isAnimationActive` (objectui#11598, N1) Both dashboard surfaces compose the palette and `isAnimationActive: false` onto the `chart` node they build for a series widget bound to inline rows. Neither face of `ChartSchema` declares the two keys, so the literal compiled only through `BaseSchema`'s index signature, which objectui#8347 removes. The literal is now checked against `DashboardChartRenderSchema` (`chartRenderHandoff.ts`), not exported from the package index and never added to `@object-ui/types`: the seat's ruling B on objectui#8347, reusing the objectui#6356 ruling carried by plugin-timeline's `renderHandoff.ts`. The node is handed to `SchemaRenderer` with no cast. Nothing drawn changes. Claude-Session: https://claude.ai/code/session_01CPvhwGcirXqBGEdPSb72TZ Co-authored-by: Claude --- .../11598-dashboard-slot-entry-reads.md | 7 + .../src/DashboardGridLayout.tsx | 8 +- .../src/DashboardRenderer.tsx | 8 +- .../chartRenderHandoff-11598.test.tsx | 141 ++++++++++++++++++ .../src/chartRenderHandoff.ts | 50 +++++++ 5 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 .changeset/11598-dashboard-slot-entry-reads.md create mode 100644 packages/plugin-dashboard/src/__tests__/chartRenderHandoff-11598.test.tsx create mode 100644 packages/plugin-dashboard/src/chartRenderHandoff.ts diff --git a/.changeset/11598-dashboard-slot-entry-reads.md b/.changeset/11598-dashboard-slot-entry-reads.md new file mode 100644 index 0000000000..a33ca6cacb --- /dev/null +++ b/.changeset/11598-dashboard-slot-entry-reads.md @@ -0,0 +1,7 @@ +--- +'@object-ui/plugin-dashboard': patch +--- + +The dashboard surfaces type the `chart` node they build without leaning on `BaseSchema`'s index signature (objectui#11598). + +**N1, the `chart` producers.** `DashboardRenderer` and `DashboardGridLayout` build a `chart` node for a series widget bound to inline rows, and compose two render keys onto it: the dashboard palette (`colors`) and `isAnimationActive: false`, the deterministic first paint inside the grid (#2756). `ChartSchema` declares neither key, on either face, and the chart renderer reads both. Each producer now checks its literal against a hand-off type private to this package, `ChartSchema` plus those two keys, and hands the node on with no cast; the type is not exported, and the keys stay off `@object-ui/types`, because the strict authoring face refuses both on an authored `chart` node. Nothing drawn changes. diff --git a/packages/plugin-dashboard/src/DashboardGridLayout.tsx b/packages/plugin-dashboard/src/DashboardGridLayout.tsx index 8f217feb6a..4450a46ffa 100644 --- a/packages/plugin-dashboard/src/DashboardGridLayout.tsx +++ b/packages/plugin-dashboard/src/DashboardGridLayout.tsx @@ -21,6 +21,7 @@ import { } from './widgetDispatch'; import { LEGACY_RETIRED_WIDGET_SCHEMA, isLegacyRetiredWidget, isRetiredEnvelopeNode } from './legacyRetiredWidget'; import { DatasetWidget } from './DatasetWidget'; +import type { DashboardChartRenderSchema } from './chartRenderHandoff'; import { useWidgetSubCaption } from './widgetSubCaption'; import { useDashboardAutoRefresh } from './useDashboardAutoRefresh'; @@ -359,7 +360,11 @@ export const DashboardGridLayout: React.FC = ({ const dataItems = Array.isArray(widgetData) ? widgetData : widgetData?.items || []; - return { + // Checked against the dashboard's chart hand-off, `ChartSchema` plus the + // two render keys this producer composes (`./chartRenderHandoff`, + // objectui#11598), as `DashboardRenderer` builds it; handed on with no + // cast. + const chartNode: DashboardChartRenderSchema = { type: 'chart', chartType: dispatch.chartType, data: dataItems, @@ -374,6 +379,7 @@ export const DashboardGridLayout: React.FC = ({ className: "h-full", ...chartPresentation, }; + return chartNode; } // Single-value families render as a metric card, not a chart (#2943). diff --git a/packages/plugin-dashboard/src/DashboardRenderer.tsx b/packages/plugin-dashboard/src/DashboardRenderer.tsx index 7b59f1307c..b2ade0234f 100644 --- a/packages/plugin-dashboard/src/DashboardRenderer.tsx +++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx @@ -44,6 +44,7 @@ import { isObjectProvider, deriveStaticTableColumns, composeSeriesLabel } from ' import { classifyWidgetType, METRIC_LIKE_TYPES, DASHBOARD_NODE_TYPES, toDashboardNodeType, resolveWidgetType, isSlotComponentEntry, unsupportedWidgetSchema, entryComponent, type DashboardWidgetSlotEntry } from './widgetDispatch'; import { LEGACY_RETIRED_WIDGET_SCHEMA, isLegacyRetiredWidget, isRetiredEnvelopeNode } from './legacyRetiredWidget'; import { DatasetWidget } from './DatasetWidget'; +import type { DashboardChartRenderSchema } from './chartRenderHandoff'; import { useWidgetSubCaption } from './widgetSubCaption'; import { useDashboardAutoRefresh } from './useDashboardAutoRefresh'; import { DashboardFilterBar } from './DashboardFilterBar'; @@ -830,7 +831,11 @@ const DashboardRendererInner = forwardRef['schema']; + +/* ── 1. type level ───────────────────────────────────────────────────────── */ + +describe('objectui#11598 N1 — the chart hand-off\'s declared types', () => { + it('is a `ChartSchema`, and `ChartRenderer` reads both extra keys with the types it gives them', () => { + const isChart: DashboardChartRenderSchema extends ChartSchema ? true : false = true; + const colors: DashboardChartRenderSchema['colors'] extends ChartRendererSchema['colors'] ? true : false = true; + const animation: DashboardChartRenderSchema['isAnimationActive'] extends ChartRendererSchema['isAnimationActive'] + ? true + : false = true; + expect([isChart, colors, animation]).toEqual([true, true, true]); + }); +}); + +/* ── 2. runtime: the node each surface hands the renderer ────────────────── */ + +/** Every `chart` node a surface handed the renderer, in order. */ +const composed: Array> = []; +const recorder = (props: { schema?: Record }) => { + if (props.schema) composed.push(props.schema); + return null; +}; +ComponentRegistry.register('chart', recorder as never, { + namespace: 'test', + label: 'recorder', + category: 'plugin', +} as never); + +afterEach(cleanup); + +/** + * A partial stub carrying no member the inline path calls: the static-data + * branch issues no query (the same crossing `DashboardChart.chartConfig-4044` + * marks with `as unknown as DataSource`). + */ +const dataSource = {} as unknown as DataSource; + +/** A series widget bound to inline rows: the branch that builds a `chart` node. */ +const INLINE_BAR = { + id: 'w1', + type: 'bar', + options: { xField: 'status', yField: 'total', data: [{ status: 'open', total: 3 }] }, +} as const; + +const composeVia = async (surface: 'grid' | 'renderer') => { + composed.length = 0; + // Stored metadata is what a renderer receives; the inline binding is not an + // authoring shape the declared widget type models, hence the crossing. + const schema = { type: 'dashboard', widgets: [INLINE_BAR] } as never; + render( + + {surface === 'grid' ? : } + , + ); + await waitFor(() => expect(composed.length).toBeGreaterThan(0)); + return composed[composed.length - 1]; +}; + +describe('objectui#11598 N1 — each surface hands the chart renderer both render keys', () => { + it.each(['renderer', 'grid'] as const)('%s', async (surface) => { + const node = await composeVia(surface); + expect(node.type).toBe('chart'); + expect(node.chartType).toBe('bar'); + expect(Array.isArray(node.colors) && (node.colors as unknown[]).length).toBeGreaterThan(0); + expect(node.isAnimationActive).toBe(false); + }); +}); + +/* ── 3. why the hand-off stays off every public type ─────────────────────── */ + +describe('objectui#11598 N1 — the strict authoring face refuses both keys on an authored `chart` node', () => { + const authored = { + type: 'chart', + chartType: 'bar', + data: [{ status: 'open', total: 3 }], + xAxisKey: 'status', + series: [{ dataKey: 'total' }], + }; + + it('CONTROL — the same node without them parses', () => { + expect(StrictAnyComponentSchema.safeParse(authored).success).toBe(true); + }); + + it.each([ + ['colors', ['#111111']], + ['isAnimationActive', false], + ] as const)('`%s`', (key, value) => { + const result = StrictAnyComponentSchema.safeParse({ ...authored, [key]: value }); + expect(result.success).toBe(false); + // Refused as an undeclared key on the node itself, naming exactly this key. + expect(result.error?.issues.map((issue) => ({ code: issue.code, path: issue.path, keys: (issue as { keys?: string[] }).keys }))).toEqual([ + { code: 'unrecognized_keys', path: [], keys: [key] }, + ]); + }); +}); diff --git a/packages/plugin-dashboard/src/chartRenderHandoff.ts b/packages/plugin-dashboard/src/chartRenderHandoff.ts new file mode 100644 index 0000000000..36f92b8b4f --- /dev/null +++ b/packages/plugin-dashboard/src/chartRenderHandoff.ts @@ -0,0 +1,50 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The shape a dashboard surface BUILDS its static-data `chart` node as — wider + * than the shape an author may WRITE, on purpose (objectui#11598, N1). + * + * The seat ruled this on objectui#8347 (Q1 = B), reusing the maintainer's + * objectui#6356 ruling (2026-09-27, Q1 = A), whose pattern and reasons live in + * `plugin-timeline`'s `renderHandoff.ts`: keys that only host code writes, with + * zero authored instances, are typed where their producer lives, and never in + * `@object-ui/types`. + * + * `@object-ui/types` declares the authored `chart` node, `ChartSchema`, and + * neither of its faces declares `colors` or `isAnimationActive`. Both dashboard + * surfaces, `DashboardRenderer` and `DashboardGridLayout`, compose the two onto + * the `chart` node they build for a series widget bound to inline rows: the + * dashboard palette (`CHART_COLORS`), and `isAnimationActive: false` for a + * deterministic first paint inside the grid (#2756). The chart renderer reads + * both: `ChartRenderer` (`@object-ui/plugin-charts`) declares them on its own + * props type and reads them with no cast. Nobody authors them on a `chart` + * node: the census behind the ruling found them written by host code only. + * + * Each producer checks its literal against this type where it builds it, then + * hands the node to `SchemaRenderer` with no cast: the type extends + * `ChartSchema`, so the node is the declared `chart` node plus these two keys. + * The `object-chart` node the same surfaces build needs none of this, because + * `ObjectChartSchema` declares both keys itself. + * + * ⛔ Deliberately NOT exported from this package's index, and ⛔ never to be + * added to `@object-ui/types`: the strict authoring face refuses both keys on + * an authored `chart` node, and a public type naming them would invite the + * spelling that face refuses. The renderer stays more lenient than validation + * — it reads them off this hand-off — which is the house posture. + */ + +import type { ChartSchema } from '@object-ui/types'; + +/** A `chart` node as a dashboard surface hands it to the chart renderer. */ +export interface DashboardChartRenderSchema extends ChartSchema { + /** The positional palette the dashboard paints the series with. */ + colors?: string[]; + /** `false` for a deterministic first paint with no entrance animation (#2756). */ + isAnimationActive?: boolean; +} From d6da79f6eb3e6ac47b270b3f7aec0902a3bac50d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 06:14:03 +0000 Subject: [PATCH 2/3] fix(plugin-dashboard,plugin-designer): a widget key is read on the widget arm alone; the either-arm `dataset` fork retires (objectui#11598, N2) Every dashboard surface reads a `widgets[]` entry by the slot's element type, the component arm (`metric-card`) or the widget arm. Twenty reads took a widget key off that union, which compiled only through `BaseSchema`'s index signature (objectui#8347 removes it), and one was a deliberate fork: "a `dataset` draws `DatasetWidget` on either arm". The seat's ruling N2 = A on objectui#8347: every read narrows with `isSlotComponentEntry` first. Both surfaces decide the slot-component passthrough before any widget key is read; `entryComponent` reads `component` on the widget arm only; the dataset fork, the filter-bar scoping and the config panel's seed read the widget arm; `DashboardEditor` narrows with a local twin of the guard (it cannot import plugin-dashboard) and offers the widget-only Color Variant select on a widget alone. Runtime moves only for a `metric-card` entry carrying a widget key, which the strict face refuses: it draws its card instead of the dataset tile, the spread `options`, or the envelope node. The two doc passages that described the dataset tile in the card's place are rewritten. Claude-Session: https://claude.ai/code/session_01CPvhwGcirXqBGEdPSb72TZ Co-authored-by: Claude --- .../11598-dashboard-slot-entry-reads.md | 11 +- content/docs/plugins/plugin-dashboard.mdx | 8 +- packages/plugin-dashboard/README.md | 8 +- .../src/DashboardGridLayout.tsx | 97 ++++++------ .../src/DashboardRenderer.tsx | 64 ++++---- .../src/DashboardWithConfig.tsx | 26 ++-- .../slotEntryWidgetArmReads-11598.test.tsx | 146 ++++++++++++++++++ .../plugin-dashboard/src/widgetDispatch.ts | 24 ++- .../plugin-designer/src/DashboardEditor.tsx | 89 ++++++++--- ...oardEditor.slotComponentArm-11598.test.tsx | 59 +++++++ 10 files changed, 407 insertions(+), 125 deletions(-) create mode 100644 packages/plugin-dashboard/src/__tests__/slotEntryWidgetArmReads-11598.test.tsx create mode 100644 packages/plugin-designer/src/__tests__/DashboardEditor.slotComponentArm-11598.test.tsx diff --git a/.changeset/11598-dashboard-slot-entry-reads.md b/.changeset/11598-dashboard-slot-entry-reads.md index a33ca6cacb..deefd16b84 100644 --- a/.changeset/11598-dashboard-slot-entry-reads.md +++ b/.changeset/11598-dashboard-slot-entry-reads.md @@ -1,7 +1,16 @@ --- '@object-ui/plugin-dashboard': patch +'@object-ui/plugin-designer': patch --- -The dashboard surfaces type the `chart` node they build without leaning on `BaseSchema`'s index signature (objectui#11598). +The dashboard surfaces read every `widgets[]` entry without leaning on `BaseSchema`'s index signature: a widget key is read on the widget arm alone, and the `chart` node is built as a private hand-off type (objectui#11598). **N1, the `chart` producers.** `DashboardRenderer` and `DashboardGridLayout` build a `chart` node for a series widget bound to inline rows, and compose two render keys onto it: the dashboard palette (`colors`) and `isAnimationActive: false`, the deterministic first paint inside the grid (#2756). `ChartSchema` declares neither key, on either face, and the chart renderer reads both. Each producer now checks its literal against a hand-off type private to this package, `ChartSchema` plus those two keys, and hands the node on with no cast; the type is not exported, and the keys stay off `@object-ui/types`, because the strict authoring face refuses both on an authored `chart` node. Nothing drawn changes. + +**N2, widget keys on the slot entry.** An entry of `widgets[]` is a widget or a component node placed in the slot (a `metric-card`), and only the widget declares the widget keys (`dataset`, `options`, `chartConfig`, `filter`, `component`, `colorVariant`, `values`, `dimensions`, …). Every read of one now narrows the entry to the widget first, on `DashboardRenderer`, `DashboardGridLayout`, `DashboardWithConfig` and `DashboardEditor`. What changes is confined to a `metric-card` entry that carries a widget key, which `@object-ui/types/zod`'s strict face refuses and only the tolerant face accepts: + +- a `metric-card` carrying `dataset` draws its card; it used to draw the dataset tile in the card's place, on both dashboard surfaces; +- a `metric-card` carrying `options` draws its own keys; `options` used to be spread over them, so `options.value` replaced `value`; +- a `metric-card` carrying a `component` draws its card; the envelope's node used to be drawn instead. + +A document the strict face accepts draws exactly as before. In `DashboardEditor`, a `metric-card` entry is no longer offered the Color Variant select: the card declares no `colorVariant`, `MetricCard` draws nothing from it, and a pick stored a key publish refuses. A widget is offered it as before. diff --git a/content/docs/plugins/plugin-dashboard.mdx b/content/docs/plugins/plugin-dashboard.mdx index ebb5f159f2..61df2fa31a 100644 --- a/content/docs/plugins/plugin-dashboard.mdx +++ b/content/docs/plugins/plugin-dashboard.mdx @@ -94,9 +94,11 @@ outside a dashboard's `widgets[]` renders nothing but the renderer's A card with no `value` is refused by `@object-ui/types/zod`, on the tolerant face and the strict one. `metric-card` is not a widget type, so a card is never -read through widget keys alone, and no widget key stands in for `value`: a -`dataset` makes the dashboard draw its dataset tile in the card's place, and -`options` is not a binding (objectui#11483). For a figure queried from a +read through widget keys, and no widget key stands in for `value`: the +dashboard reads a widget key (`dataset`, `options`, `component`, +`colorVariant`, …) on a widget alone, so a card draws its own keys whatever +else it carries, and the strict face refuses a widget key on it +(objectui#11483, objectui#11598). For a figure queried from a dataset, write a `metric` widget with `dataset` and `values`. ## Usage diff --git a/packages/plugin-dashboard/README.md b/packages/plugin-dashboard/README.md index 4010c5c9ff..e9009338ab 100644 --- a/packages/plugin-dashboard/README.md +++ b/packages/plugin-dashboard/README.md @@ -207,9 +207,11 @@ or the spec's inline per-locale map (`I18nLabel`). A card with no `value` is refused by `@object-ui/types/zod`, on the tolerant face and the strict one. `metric-card` is not a widget type, so a card is never -read through widget keys alone, and no widget key stands in for `value`: a -`dataset` makes the dashboard draw its dataset tile in the card's place, and -`options` is not a binding (objectui#11483). For a figure queried from a +read through widget keys, and no widget key stands in for `value`: the +dashboard reads a widget key (`dataset`, `options`, `component`, +`colorVariant`, …) on a widget alone, so a card draws its own keys whatever +else it carries, and the strict face refuses a widget key on it +(objectui#11483, objectui#11598). For a figure queried from a dataset, write a `metric` widget with `dataset` and `values` (see "TypeScript Support" below). diff --git a/packages/plugin-dashboard/src/DashboardGridLayout.tsx b/packages/plugin-dashboard/src/DashboardGridLayout.tsx index 4450a46ffa..9f6745b757 100644 --- a/packages/plugin-dashboard/src/DashboardGridLayout.tsx +++ b/packages/plugin-dashboard/src/DashboardGridLayout.tsx @@ -57,11 +57,11 @@ export interface DashboardGridLayoutProps { * `DatasetWidget` for ADR-0021 dataset-bound widgets (objectui#4614). * * Typed `unknown`, not `any`. The sibling declares `dataSource?: any` - * (`DashboardRenderer.tsx:198`) for a reason that is explicitly historical — + * (`DashboardRendererProps.dataSource`) for a reason that is explicitly historical — * "that is precisely what it resolved to before", via the index signature that * used to answer for it — and this is a NEW declaration with no prior * resolution to preserve. `unknown` is what the consumer itself declares - * (`DatasetWidget.tsx:655`, `dataSource: unknown`), so the value is forwarded + * (`DatasetWidget`'s own parameter, `dataSource: unknown`), so the value is forwarded * to exactly the type that receives it, and no call site is held to anything * new: every value is assignable to `unknown`. Narrowing it further to a real * adapter type is a separate change with its own consumer sweep, on both @@ -74,10 +74,11 @@ export interface DashboardGridLayoutProps { * A dataset-bound widget arriving that way renders `DatasetWidget`'s own * no-capability diagnostic — a visible state, never a blank tile. * - * `SchemaRenderer` forwards this as a React prop (its `...props` spread, last: - * `SchemaRenderer.tsx:632`). It cannot be shadowed by the spec's per-element - * `dataSource` BINDING, which is stripped from the schema before that spread - * for exactly this reason (`SchemaRenderer.tsx:564-575`, objectstack#5576). + * `SchemaRenderer` forwards this as a React prop (its `...props` spread, + * last). It cannot be shadowed by the spec's per-element `dataSource` + * BINDING, which is stripped from the schema before that spread for exactly + * this reason (the `dataSource: _dataSource` strip in `SchemaRenderer`'s + * destructure, objectstack#5576). */ dataSource?: unknown; /** @@ -259,6 +260,14 @@ export const DashboardGridLayout: React.FC = ({ // that prop's (objectui#11466): each node is checked against its declared // type where it is built, and the render sites below take it with no cast. const getComponentSchema = React.useCallback((widget: DashboardWidgetSlotEntry): SchemaRendererProps['schema'] => { + // The slot-component passthrough serves the slot's component arm alone and + // takes the namespaced node key too (`toDashboardNodeType`, objectui#10859 + // batch 8; objectui#11514, Q2 A). Decided FIRST, as `DashboardRenderer` + // decides it, and reading no widget key (objectui#11598, N2 A): below it + // `widget` is the widget arm, so every widget key is read off the arm that + // declares it. + if (isSlotComponentEntry(widget)) return toDashboardNodeType({ ...widget }); + // Same boundary as `DashboardRenderer`: the author's node keeps its // spelling except a `metric` / `metric-card` node key, which moves onto its // namespaced registration (`toDashboardNodeType`, objectui#10859 batch 8). @@ -507,17 +516,9 @@ export const DashboardGridLayout: React.FC = ({ return unsupportedWidgetSchema(widgetType); } - // The slot-component passthrough serves the slot's component arm alone and - // takes the namespaced node key too (`toDashboardNodeType`, objectui#10859 - // batch 8; objectui#11514, Q2 A). Any other entry here names no family: - // stale metadata, drawn as the labelled placeholder, as `DashboardRenderer` - // draws it. - if (isSlotComponentEntry(widget)) { - return toDashboardNodeType({ - ...widget, - ...options - }); - } + // An entry here names no family and is no component node (the passthrough + // above took those): stale metadata, drawn as the labelled placeholder, as + // `DashboardRenderer` draws it. return unsupportedWidgetSchema(widgetType); }, [resolveSeriesLabel]); @@ -619,33 +620,31 @@ export const DashboardGridLayout: React.FC = ({ // ADR-0021 — a widget bound to a semantic-layer dataset renders // through the governed queryDataset path (DatasetWidget) instead of // the inline object-aggregate schema. Decided per widget AT THE - // RENDER SITE, which is `DashboardRenderer`'s own mechanic - // (`DashboardRenderer.tsx:524` for the predicate, `:849-851` for the - // fork) rather than a second dispatch idiom invented here: this - // surface had NO dataset path at all, so every current-shape widget - // fell through `getComponentSchema` to the static-data branch and - // drew `data: []` — a blank chart, an em-dash metric or an empty - // table depending on the family, with no diagnostic (objectui#4614). - // The cast names the ONE key it reads, rather than reaching for - // `as any` the way the sibling does (`DashboardRenderer.tsx:524`): - // the bundled DashboardWidget type gains `dataset` only after - // objectui bumps @objectstack/spec, and `legacyRetiredWidget.ts` - // already answers that same problem in this package by naming the - // undeclared keys in a shape of its own — "what keeps `as any` out - // of both call sites" (`legacyRetiredWidget.ts:64-80`). One key is - // read here, so the shape is stated inline. + // RENDER SITE, which is `DashboardRenderer`'s own mechanic rather + // than a second dispatch idiom invented here: this surface had NO + // dataset path at all, so every current-shape widget fell through + // `getComponentSchema` to the static-data branch and drew + // `data: []` — a blank chart, an em-dash metric or an empty table + // depending on the family, with no diagnostic (objectui#4614). + // + // `dataset` is a widget key, read on the widget arm alone + // (objectui#11598, N2 A), as `DashboardRenderer` reads it: a + // component node in the slot (a `metric-card`) draws itself, + // whatever else it carries. The read used to be a cast naming the + // one key, so a `metric-card` the strict face refuses for its + // `dataset` drew `DatasetWidget` in the card's place. // // Position relative to the objectui#4612 legacy sentinel (which // stays where it is, inside `getComponentSchema` BEFORE the dispatch // branches): the two conditions are MUTUALLY EXCLUSIVE by // construction, because `isLegacyRetiredWidget` returns false the - // moment a widget carries `dataset` (`legacyRetiredWidget.ts:107`). - // Neither can capture the other's widget, so their relative order - // cannot change a verdict on either surface — the same arrangement - // `DashboardRenderer` has carried since #4612, and the reason that - // module states step 1 on its own terms instead of inheriting it - // from a caller's fork (`legacyRetiredWidget.ts:97-102`). - const datasetBound = !!(widget as { dataset?: unknown }).dataset; + // moment a widget carries `dataset` (its step 1). Neither can capture + // the other's widget, so their relative order cannot change a verdict + // on either surface — the same arrangement `DashboardRenderer` has + // carried since #4612, and the reason that module states step 1 on + // its own terms instead of inheriting it from a caller's fork. + const datasetWidget = !isSlotComponentEntry(widget) && widget.dataset ? widget : undefined; + const datasetBound = datasetWidget !== undefined; // A `metric` widget renders its own card chrome ONLY in the inline // path (the `plugin-dashboard:metric` card, or a retired // `provider: 'object'` metric's placeholder, objectui#11525). A @@ -694,27 +693,23 @@ export const DashboardGridLayout: React.FC = ({
{/* - The fork itself, mirroring `DashboardRenderer.tsx:849-851`. - `widget` is passed whole: DatasetWidget reads + The fork itself, mirroring `DashboardRenderer`'s. + The widget arm is passed whole: DatasetWidget reads `widget.filter` and forwards it to the query as `runtimeFilter`, so an authored per-widget filter still - applies. The sibling wraps it as `effectiveWidget` only - to merge in the dashboard FILTER BAR's scoped filter, + applies. The sibling copies it as its `datasetWidget` + only to merge in the dashboard FILTER BAR's scoped filter, which this surface does not have — there is no `scopedFilter` here to merge, so re-creating the wrapper would state a dependency that does not exist. This is the ONLY fork: the self-contained branch above cannot be reached by a dataset-bound widget, since - `isSelfContained` now requires `!datasetBound`. The - sibling carries a second, identical fork inside its - self-contained branch (`:820-822`) that is unreachable - for that same reason; the reachable mechanics are what - is mirrored here, not the stranded limb. + `isSelfContained` now requires `!datasetBound`. */} - {datasetBound + {datasetWidget ? = ({ this one silently unchanged. `?? null` says "a surface resolved it, to nothing", which is NOT the same as the prop being absent — see the prop's docblock. */ - subCaption={tWidgetSubCaption(widget) ?? null} + subCaption={tWidgetSubCaption(datasetWidget) ?? null} /> : }
diff --git a/packages/plugin-dashboard/src/DashboardRenderer.tsx b/packages/plugin-dashboard/src/DashboardRenderer.tsx index b2ade0234f..8cbe6d5a3d 100644 --- a/packages/plugin-dashboard/src/DashboardRenderer.tsx +++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx @@ -685,21 +685,39 @@ const DashboardRendererInner = forwardRef { + // The slot-component passthrough (the 2026-08-14 `metric-card` slot + // ruling) serves the slot's component arm alone (objectui#11514, + // Q2 A): the entry keeps its `type`, the node it becomes takes the + // namespaced key (`toDashboardNodeType`, objectui#10859 batch 8). + // It is decided FIRST and reads no widget key (objectui#11598, + // N2 A), so below it `widget` is the widget arm and every widget + // key is read off the arm that declares it. A card used to take + // `options` spread over its own keys here, a second spelling of + // its props the strict face refuses (objectui#11483). + if (isSlotComponentEntry(widget)) return toDashboardNodeType({ ...widget }); + // The author-supplied node keeps its spelling; only a `metric` / // `metric-card` node key moves onto its namespaced registration // (`toDashboardNodeType`, objectui#10859 batch 8). @@ -1007,21 +1025,12 @@ const DashboardRendererInner = forwardRef 0 - ? buildWidgetScopedFilter(widget, filterDefs, filterValues) + // Widget arm only: `filterBindings` and `filter` are widget keys, and + // the node a component entry becomes takes no filter (objectui#11598). + const scopedFilter = widgetArm && filterDefs.length > 0 + ? buildWidgetScopedFilter(widgetArm, filterDefs, filterValues) : undefined; const componentSchema = ((): SchemaRendererProps['schema'] => { // No cast (objectui#11466): `getComponentSchema` returns @@ -1048,10 +1059,11 @@ const DashboardRendererInner = forwardRef
- {datasetBound + {datasetWidget ? : }
diff --git a/packages/plugin-dashboard/src/DashboardWithConfig.tsx b/packages/plugin-dashboard/src/DashboardWithConfig.tsx index 1fcc1c58c7..15e0c3a323 100644 --- a/packages/plugin-dashboard/src/DashboardWithConfig.tsx +++ b/packages/plugin-dashboard/src/DashboardWithConfig.tsx @@ -11,7 +11,7 @@ import { useState, useCallback, useEffect } from 'react'; import { Settings } from 'lucide-react'; import { cn, Button } from '@object-ui/components'; import type { DashboardComponentSchema } from '@object-ui/types'; -import type { DashboardWidgetSlotEntry } from './widgetDispatch'; +import { isSlotComponentEntry, type DashboardWidgetSlotEntry } from './widgetDispatch'; import { completeWidgetLayout, defaultWidgetPlacement } from '@object-ui/types'; import { DashboardRenderer } from './DashboardRenderer'; @@ -100,8 +100,7 @@ export function DashboardWithConfig({ // dashboard surface reads a `widgets[]` entry. `title` and `layout` below // are declared on both arms: the widget arm's spec `DashboardWidget` row, // and the component arm's card heading and the spec's `layout` by reference - // (objectui#11070 round 11). `colorVariant` is the widget arm's member; on - // the component arm the passthrough reads it. The component arm + // (objectui#11070 round 11). The component arm // (`DashboardWidgetSlotComponentSchema`) is no longer assignable to the // widget arm, whose `type` names no component type since objectui#11514. const widgets: DashboardWidgetSlotEntry[] = liveSchema.widgets; @@ -115,18 +114,25 @@ export function DashboardWithConfig({ // 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`. - const w = widget as any; + // `dataset` / `dimensions` / `values` / `colorVariant` are widget keys, + // read on the widget arm alone (objectui#11598, N2 A): a component node in + // the slot (a `metric-card`) declares none of them, so the panel starts it + // from their empty values. They used to be read off the entry whichever + // arm it was, the first three through an `as any` cast and `colorVariant` + // through `BaseSchema`'s index signature. + const widgetArm = isSlotComponentEntry(widget) ? undefined : widget; + const dataset = widgetArm?.dataset; + const dimensions = widgetArm?.dimensions; + const values = widgetArm?.values; return { id: widget.id ?? '', title: widget.title ?? '', description: widget.description ?? '', type: widget.type ?? '', - dataset: typeof w.dataset === 'string' ? w.dataset : '', - dimensions: Array.isArray(w.dimensions) ? w.dimensions : [], - values: Array.isArray(w.values) ? w.values : [], - colorVariant: widget.colorVariant ?? 'default', + dataset: typeof dataset === 'string' ? dataset : '', + dimensions: Array.isArray(dimensions) ? dimensions : [], + values: Array.isArray(values) ? values : [], + colorVariant: widgetArm?.colorVariant ?? 'default', // No `actionUrl` / `actionType` / `actionIcon`: retired at the widget // level in @objectstack/spec 17.0.0-rc.3 (objectstack#5010, ADR-0049 D2) // and now `retiredKey` tombstones the spec refuses. Seeding diff --git a/packages/plugin-dashboard/src/__tests__/slotEntryWidgetArmReads-11598.test.tsx b/packages/plugin-dashboard/src/__tests__/slotEntryWidgetArmReads-11598.test.tsx new file mode 100644 index 0000000000..f9cc328802 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/slotEntryWidgetArmReads-11598.test.tsx @@ -0,0 +1,146 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11598, N2 — a widget key is read on the widget arm alone. + * + * Every dashboard surface reads a `widgets[]` entry by the slot's element type, + * the component arm (`metric-card`) or the widget arm. The surfaces used to + * read widget keys off the entry whichever arm it was, through `BaseSchema`'s + * index signature, and one read was a deliberate fork: "a `dataset` draws + * `DatasetWidget` on either arm". The seat ruled N2 = A on objectui#8347: every + * read narrows with `isSlotComponentEntry`, the either-arm fork and + * `entryComponent`'s cross-arm read retire. + * + * ## What is pinned + * + * 1. The narrowed `dataset` path, both ways, on BOTH surfaces: a widget-arm + * entry with `dataset` draws `DatasetWidget`; a `metric-card` entry + * carrying `dataset` does not reach it and draws its card. + * 2. The other cross-arm reads a card used to take: `options` spread over the + * card's own keys, and a `component` envelope drawn in the card's place. + * 3. The change is confined to documents the strict face refuses: each card + * in 1–2 is refused by `StrictAnyComponentSchema` and accepted by the + * tolerant `AnyComponentSchema`, and the strict-valid card (the CONTROL) + * draws as before. + * + * `DatasetWidget` is told apart by its `dataset-loading` skeleton under a + * query that never settles (the idiom `DashboardGridLayout.datasetPath.test` + * uses): nothing else on either surface emits that test id. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import '@object-ui/components'; +import '@object-ui/plugin-charts'; +import '../index'; +import { DashboardRenderer } from '../DashboardRenderer'; +import { DashboardGridLayout } from '../DashboardGridLayout'; +import { entryComponent, type DashboardWidgetSlotEntry } from '../widgetDispatch'; +import { AnyComponentSchema, StrictAnyComponentSchema } from '@object-ui/types/zod'; + +afterEach(cleanup); + +/** A capable source whose query never settles, so `DatasetWidget` stays on its skeleton. */ +const pendingSource = () => ({ queryDataset: vi.fn(() => new Promise>(() => {})) }); + +/** A strict-valid card: the component arm's own keys. */ +const CARD = { id: 'c1', type: 'metric-card', title: 'Revenue', value: '$24k' } as const; + +/** A widget-arm entry bound to a dataset. */ +const BOUND_WIDGET = { id: 'w1', type: 'bar', dataset: 'invoices', dimensions: ['status'], values: ['count'] } as const; + +/** Cards carrying a widget key the component arm does not declare. */ +const CARD_WITH_DATASET = { ...CARD, dataset: 'invoices', values: ['count'] }; +const CARD_WITH_OPTIONS = { ...CARD, options: { value: 'OPTIONS-FIGURE' } }; +const CARD_WITH_COMPONENT = { ...CARD, component: { type: 'text', content: 'ENVELOPE-NODE' } }; + +const doc = (widget: Record) => ({ type: 'dashboard', widgets: [widget] }); + +/** + * Stored metadata is what a surface receives: the three cards above parse only + * on the tolerant face, so they cross the declared type once, here. + */ +const renderOn = (surface: 'renderer' | 'grid', widget: Record, dataSource: unknown) => { + const schema = doc(widget) as never; + return surface === 'renderer' + ? render() + : render(); +}; + +const SURFACES = ['renderer', 'grid'] as const; + +/* ── 1. the narrowed `dataset` path, both ways ───────────────────────────── */ + +describe('objectui#11598 N2 — `dataset` draws `DatasetWidget` on the widget arm alone', () => { + it.each(SURFACES)('%s: a widget-arm entry with `dataset` draws `DatasetWidget`', (surface) => { + const src = pendingSource(); + renderOn(surface, BOUND_WIDGET, src); + expect(screen.getByTestId('dataset-loading')).toBeInTheDocument(); + expect(src.queryDataset).toHaveBeenCalledWith('invoices', { dimensions: ['status'], measures: ['count'] }); + }); + + it.each(SURFACES)('%s: a `metric-card` entry carrying `dataset` does not reach it, and draws its card', async (surface) => { + const src = pendingSource(); + renderOn(surface, CARD_WITH_DATASET, src); + expect(await screen.findByText('$24k')).toBeInTheDocument(); + expect(screen.queryByTestId('dataset-loading')).not.toBeInTheDocument(); + expect(src.queryDataset).not.toHaveBeenCalled(); + }); + + it.each(SURFACES)('%s: CONTROL — the strict-valid card draws its figure and sends no query', async (surface) => { + const src = pendingSource(); + renderOn(surface, CARD, src); + expect(await screen.findByText('$24k')).toBeInTheDocument(); + expect(screen.queryByTestId('dataset-loading')).not.toBeInTheDocument(); + expect(src.queryDataset).not.toHaveBeenCalled(); + }); +}); + +/* ── 2. the card's other cross-arm reads ─────────────────────────────────── */ + +describe('objectui#11598 N2 — a card takes no other widget key', () => { + it.each(SURFACES)('%s: `options` is not spread over the card\'s own keys', async (surface) => { + renderOn(surface, CARD_WITH_OPTIONS, pendingSource()); + expect(await screen.findByText('$24k')).toBeInTheDocument(); + expect(screen.queryByText('OPTIONS-FIGURE')).not.toBeInTheDocument(); + }); + + it.each(SURFACES)('%s: a `component` envelope is not drawn in the card\'s place', async (surface) => { + renderOn(surface, CARD_WITH_COMPONENT, pendingSource()); + expect(await screen.findByText('$24k')).toBeInTheDocument(); + expect(screen.queryByText('ENVELOPE-NODE')).not.toBeInTheDocument(); + }); + + it('`entryComponent` reads the envelope on the widget arm alone', () => { + const envelope = { type: 'text', content: 'ENVELOPE-NODE' } as const; + const widgetArm: DashboardWidgetSlotEntry = { id: 'w2', component: envelope }; + expect(entryComponent(widgetArm)).toBe(envelope); + expect(entryComponent(CARD_WITH_COMPONENT as unknown as DashboardWidgetSlotEntry)).toBeUndefined(); + }); +}); + +/* ── 3. confined to documents the strict face refuses ────────────────────── */ + +describe('objectui#11598 N2 — every card whose drawing moved is refused by the strict face', () => { + it.each([ + ['dataset', CARD_WITH_DATASET], + ['options', CARD_WITH_OPTIONS], + ['component', CARD_WITH_COMPONENT], + ] as const)('a card carrying `%s`: strict refuses it at the widget, the tolerant face accepts it', (_key, card) => { + const strict = StrictAnyComponentSchema.safeParse(doc(card)); + expect(strict.success).toBe(false); + expect(strict.error?.issues.map((issue) => `${issue.code}@${issue.path.join('.')}`)).toEqual(['invalid_union@widgets.0']); + expect(AnyComponentSchema.safeParse(doc(card)).success).toBe(true); + }); + + it('CONTROL — the card without them parses on the strict face', () => { + expect(StrictAnyComponentSchema.safeParse(doc(CARD)).success).toBe(true); + }); +}); diff --git a/packages/plugin-dashboard/src/widgetDispatch.ts b/packages/plugin-dashboard/src/widgetDispatch.ts index 42dfb3ba19..2daed62abc 100644 --- a/packages/plugin-dashboard/src/widgetDispatch.ts +++ b/packages/plugin-dashboard/src/widgetDispatch.ts @@ -45,6 +45,14 @@ import type { MetricWidgetProps } from './MetricWidget'; * (objectui#11514). The widget arm, `DashboardWidgetSchema`, is not a read type * for an entry: since objectui#11483's closure its `type` names the widget * vocabulary only, so the component arm is not assignable to it. + * + * ⛔ A widget key (`dataset`, `options`, `chartConfig`, `filter`, `component`, + * …) is read on the widget arm alone: narrow the entry with + * {@link isSlotComponentEntry} first (objectui#11598, N2 A), the read rule + * `DashboardComponentSchema.widgets` states. The component arm declares none + * of them, and a component node in the slot draws itself whatever else it + * carries; reading one off the entry whichever arm it is compiled only through + * `BaseSchema`'s index signature, which objectui#8347 removes. */ export type DashboardWidgetSlotEntry = DashboardComponentSchema['widgets'][number]; @@ -90,14 +98,18 @@ export function resolveWidgetType(entry: DashboardWidgetSlotEntry): DashboardWid /** * The node an entry's legacy `component` envelope holds, typed as the widget - * arm declares that member (`DashboardWidgetSchema['component']`). Read off the - * entry whichever arm it is, as the surfaces always read it. The component arm - * declares no `component`, so a bare `entry.component` on the slot entry reads - * through `BaseSchema`'s index signature and is `any` — which made each - * surface's whole `getComponentSchema` return `any` (objectui#11514). + * arm declares that member (`DashboardWidgetSchema['component']`), or + * `undefined` for a component node in the slot. + * + * `component` is a widget key, so it is read on the widget arm alone + * (objectui#11598, N2 A). It used to be read off the entry whichever arm it + * was: the component arm declares no `component`, so that read went through + * `BaseSchema`'s index signature, and a `metric-card` entry carrying one (a + * document the strict face refuses) drew the envelope's node in the card's + * place. */ export function entryComponent(entry: DashboardWidgetSlotEntry): DashboardWidgetSchema['component'] { - return entry.component; + return isSlotComponentEntry(entry) ? undefined : entry.component; } /** diff --git a/packages/plugin-designer/src/DashboardEditor.tsx b/packages/plugin-designer/src/DashboardEditor.tsx index 2d09d580fc..c36b87233e 100644 --- a/packages/plugin-designer/src/DashboardEditor.tsx +++ b/packages/plugin-designer/src/DashboardEditor.tsx @@ -24,7 +24,12 @@ */ import React, { useState, useCallback, useEffect, useRef } from 'react'; -import type { DashboardComponentSchema, DashboardWidgetSchema, DashboardWidgetTypeName } from '@object-ui/types'; +import type { + DashboardComponentSchema, + DashboardWidgetSchema, + DashboardWidgetSlotComponentSchema, + DashboardWidgetTypeName, +} from '@object-ui/types'; import { Trash2, GripVertical, @@ -45,7 +50,7 @@ import { } from 'lucide-react'; import { clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; -import { completeWidgetLayout, defaultWidgetPlacement } from '@object-ui/types'; +import { DASHBOARD_COMPONENT_WIDGET_TYPES, completeWidgetLayout, defaultWidgetPlacement } from '@object-ui/types'; import { DashboardWidgetSchema as DashboardWidgetDoor } from '@object-ui/types/zod'; import { pickLocalized, setLocalized } from '@object-ui/i18n'; import { useUndoRedo } from './hooks/useUndoRedo'; @@ -86,6 +91,26 @@ export interface DashboardEditorProps { */ type DashboardWidgetEntry = DashboardComponentSchema['widgets'][number]; +/** + * Whether an entry is the slot's component arm (a `metric-card`): its `type` + * is a member of the closed `DASHBOARD_COMPONENT_WIDGET_TYPES`, which no + * widget `type` names (objectui#11483). + * + * A widget key (`values`, `dimensions`, `colorVariant`, …) is read on the + * widget arm alone, so every such read below narrows with this first + * (objectui#11598, N2 A): the component arm declares none of them, and reading + * one off the entry whichever arm it was compiled only through `BaseSchema`'s + * index signature, which objectui#8347 removes. + * + * The twin of plugin-dashboard's `isSlotComponentEntry` (`widgetDispatch.ts`), + * which this package cannot import: it does not depend on plugin-dashboard + * (the same reason `measureRefusal` below is a twin). Both read the one + * closed list from `@object-ui/types`, so they cannot disagree on membership. + */ +function isSlotComponentEntry(entry: DashboardWidgetEntry): entry is DashboardWidgetSlotComponentSchema { + return (DASHBOARD_COMPONENT_WIDGET_TYPES as readonly unknown[]).includes(entry.type); +} + // ============================================================================ // Constants // ============================================================================ @@ -246,11 +271,15 @@ function writeWidgetTitle( function measureRefusal(widget: DashboardWidgetEntry, type: string | undefined): string | undefined { const strings = (value: unknown): string[] => Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []; + // The measures are widget keys, read on the widget arm alone: a component + // node in the slot carries none, so it is probed with none (objectui#11598). + const measures = isSlotComponentEntry(widget) ? undefined : widget; + const dimensions = measures?.dimensions; const probe = { ...(typeof widget.id === 'string' ? { id: widget.id } : {}), ...(type ? { type } : {}), - values: strings(widget.values), - ...(Array.isArray(widget.dimensions) ? { dimensions: strings(widget.dimensions) } : {}), + values: strings(measures?.values), + ...(Array.isArray(dimensions) ? { dimensions: strings(dimensions) } : {}), }; const result = DashboardWidgetDoor.safeParse(probe); if (result.success) return undefined; @@ -394,6 +423,10 @@ function WidgetPropertyPanel({ const currentType = widget.type ?? 'metric'; const refusedUnder = (type: string) => measureRefusal(widget, type) !== undefined; const currentRefusal = measureRefusal(widget, widget.type); + // The widget arm, or `undefined` for a component node in the slot (a + // `metric-card`), which declares no widget key: the widget-only fields below + // are offered on the widget arm alone (objectui#11598, N2 A). + const widgetArm = isSlotComponentEntry(widget) ? undefined : widget; return (
- - -
+ {/* Color variant — a widget key, so offered on the widget arm alone + (objectui#11598, N2 A). A `metric-card` declares no `colorVariant`: + the strict face refuses one on the card and `MetricCard` draws + nothing from it, so offering the select there could only store a key + publish refuses (AGENTS.md #0.1). */} + {widgetArm && ( +
+ + +
+ )} {/* Widget size */}
diff --git a/packages/plugin-designer/src/__tests__/DashboardEditor.slotComponentArm-11598.test.tsx b/packages/plugin-designer/src/__tests__/DashboardEditor.slotComponentArm-11598.test.tsx new file mode 100644 index 0000000000..d3d1be685e --- /dev/null +++ b/packages/plugin-designer/src/__tests__/DashboardEditor.slotComponentArm-11598.test.tsx @@ -0,0 +1,59 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11598, N2 — `DashboardEditor` reads a widget key on the widget arm + * alone, so a `metric-card` entry is not offered the widget-only Color Variant. + * + * The property panel read `colorVariant` off the selected entry whichever arm + * it was, through `BaseSchema`'s index signature, and offered the select for a + * `metric-card` too. The card declares no `colorVariant`: the strict face + * refuses one on it and `MetricCard` draws nothing from it, so the select there + * could only write a key publish refuses. The panel's other controls (title, + * size) read keys both arms declare and stay. + */ + +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { DashboardComponentSchema } from '@object-ui/types'; +import { DashboardEditor } from '../DashboardEditor'; + +const WIDGETS: DashboardComponentSchema['widgets'] = [ + { id: 'w1', type: 'bar', title: 'Pipeline', dataset: 'sales_pipeline', dimensions: ['stage'], values: ['revenue'] }, + { id: 'c1', type: 'metric-card', title: 'Revenue', value: '$24k' }, +]; + +function openPanelFor(id: string) { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByTestId(`dashboard-widget-${id}`)); + return onChange; +} + +describe('DashboardEditor — the widget-only Color Variant is offered on the widget arm alone (objectui#11598)', () => { + it('a widget-arm entry is offered it, and a pick writes `colorVariant` on that widget', () => { + const onChange = openPanelFor('w1'); + fireEvent.change(screen.getByTestId('widget-prop-color'), { target: { value: 'blue' } }); + const schema = onChange.mock.calls[onChange.mock.calls.length - 1][0] as DashboardComponentSchema; + expect(schema.widgets.find((w) => w.id === 'w1')).toMatchObject({ colorVariant: 'blue' }); + }); + + it('a `metric-card` entry is not offered it', () => { + openPanelFor('c1'); + expect(screen.getByTestId('widget-property-panel')).toBeInTheDocument(); + expect(screen.queryByTestId('widget-prop-color')).not.toBeInTheDocument(); + }); + + it('CONTROL — the `metric-card` entry keeps the controls for keys both arms declare', () => { + openPanelFor('c1'); + expect(screen.getByTestId('widget-prop-title')).toBeInTheDocument(); + expect(screen.getByTestId('widget-prop-width')).toBeInTheDocument(); + expect(screen.getByTestId('widget-prop-height')).toBeInTheDocument(); + }); +}); From b9816940e8db9dcd0867f10cd9239f2a715edfa7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 06:44:39 +0000 Subject: [PATCH 3/3] docs(types): the `metric-card` notes say a widget key is read on the widget arm alone, as of objectui#11598 Three notes in `@object-ui/types` described the either-arm draw as live: `DASHBOARD_COMPONENT_WIDGET_TYPES`' docblock ("a `dataset` draws `DatasetWidget` in the card's place by a rule that ignores the `type`"), the Zod widget vocabulary's docblock (the same, plus "The `options` bag is spread onto the node as literal props"), and the objectui#11483 test's header and two inline comments. The previous commit on this branch retired both behaviours, so each note now dates the objectui#11483 measurement and states what holds since objectui#11598: a `metric-card` carrying a widget key is refused by the strict face and is not drawn as a widget. Comment and prose only; no type, schema or test assertion changes. The changeset declares `@object-ui/types` for the shipped JSDoc. Claude-Session: https://claude.ai/code/session_01CPvhwGcirXqBGEdPSb72TZ Co-authored-by: Claude --- .../11598-dashboard-slot-entry-reads.md | 3 ++- .../metric-card-needs-value-11483.test.ts | 22 +++++++++++-------- packages/types/src/complex.ts | 10 ++++++--- packages/types/src/zod/complex.zod.ts | 12 ++++++---- 4 files changed, 30 insertions(+), 17 deletions(-) diff --git a/.changeset/11598-dashboard-slot-entry-reads.md b/.changeset/11598-dashboard-slot-entry-reads.md index deefd16b84..ca09124af2 100644 --- a/.changeset/11598-dashboard-slot-entry-reads.md +++ b/.changeset/11598-dashboard-slot-entry-reads.md @@ -1,6 +1,7 @@ --- '@object-ui/plugin-dashboard': patch '@object-ui/plugin-designer': patch +'@object-ui/types': patch --- The dashboard surfaces read every `widgets[]` entry without leaning on `BaseSchema`'s index signature: a widget key is read on the widget arm alone, and the `chart` node is built as a private hand-off type (objectui#11598). @@ -13,4 +14,4 @@ The dashboard surfaces read every `widgets[]` entry without leaning on `BaseSche - a `metric-card` carrying `options` draws its own keys; `options` used to be spread over them, so `options.value` replaced `value`; - a `metric-card` carrying a `component` draws its card; the envelope's node used to be drawn instead. -A document the strict face accepts draws exactly as before. In `DashboardEditor`, a `metric-card` entry is no longer offered the Color Variant select: the card declares no `colorVariant`, `MetricCard` draws nothing from it, and a pick stored a key publish refuses. A widget is offered it as before. +A document the strict face accepts draws exactly as before. `@object-ui/types`' docblocks on `DASHBOARD_COMPONENT_WIDGET_TYPES` and the Zod widget vocabulary, which described the dataset tile in the card's place and the `options` spread as live, were corrected to match; no type in it changes. In `DashboardEditor`, a `metric-card` entry is no longer offered the Color Variant select: the card declares no `colorVariant`, `MetricCard` draws nothing from it, and a pick stored a key publish refuses. A widget is offered it as before. diff --git a/packages/types/src/__tests__/metric-card-needs-value-11483.test.ts b/packages/types/src/__tests__/metric-card-needs-value-11483.test.ts index 55d112c907..591b943b9f 100644 --- a/packages/types/src/__tests__/metric-card-needs-value-11483.test.ts +++ b/packages/types/src/__tests__/metric-card-needs-value-11483.test.ts @@ -26,10 +26,12 @@ * carries what the card draws from, which is its `value`, or the widget arm's * own data binding if that arm declares one. With neither, it is refused. * Measured through the real `DashboardRenderer` and `DashboardGridLayout`, no - * widget key binds the card's figure. A `dataset` makes the dashboard draw - * `DatasetWidget` in the card's place, by a rule that ignores the `type`. The - * `options` bag is spread onto the node as literal props, which is not a - * binding. So `metric-card` left the widget vocabulary, and the component + * widget key binds the card's figure: a `dataset` then drew `DatasetWidget` in + * the card's place, by a rule that ignored the `type`, and the `options` bag + * was spread onto the node as literal props, which is not a binding. Since + * objectui#11598 (2026-10-04) both surfaces read a widget key on the widget + * arm alone, so a card carrying one draws its own keys and is not drawn as a + * widget. So `metric-card` left the widget vocabulary, and the component * arm's required `value` governs alone. The probe's readings are in that * card's pull request; this file pins the contract, not the probe. * @@ -129,8 +131,9 @@ describe('objectui#11483 — a card with its `value` passes on every face', () = describe('objectui#11483 — no widget key stands in for the card\'s `value`', () => { it.each(FACES)('%s: a dataset-bound card is refused at `value`', (_name, face) => { - // The dashboard draws `DatasetWidget` for any widget with a `dataset`, in the - // card's place: the card is never mounted, so `dataset` is not its binding. + // `dataset` is not the card's binding. The dashboard used to draw + // `DatasetWidget` in the card's place for it; since objectui#11598 it reads + // `dataset` on the widget arm alone, so the card draws its own keys. const refusal = slotRefusal(face, dashboard({ type: 'metric-card', title: 'Revenue', dataset: 'sales', values: ['revenue'] })); expect(refusal?.code).toBe('invalid_union'); expect(paths(refusal?.errors?.[0])).toContain('invalid_union@value'); @@ -138,9 +141,10 @@ describe('objectui#11483 — no widget key stands in for the card\'s `value`', ( }); it.each(FACES)('%s: a card whose figure sits in `options` is refused at `value`', (_name, face) => { - // The surfaces spread `options` onto the node, so `options.value` reached the - // card as its prop: a second spelling of `value`, which the tolerant face - // accepted until this change. + // The surfaces used to spread `options` onto the node, so `options.value` + // reached the card as its prop: a second spelling of `value`, which the + // tolerant face accepted until this change. Since objectui#11598 no surface + // reads `options` off a card. const refusal = slotRefusal(face, dashboard({ type: 'metric-card', title: 'Revenue', options: { value: '$24k' } })); expect(refusal?.code).toBe('invalid_union'); expect(paths(refusal?.errors?.[0])).toContain('invalid_union@value'); diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index 4e83c7ed09..bb81b4c587 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -2143,9 +2143,13 @@ export interface FloatingChatbotConfig { * alone, and that arm's required `value` is the one spelling of what the card * draws from. While it was also a widget type, `{ type: 'metric-card', title }` * parsed as a widget and `MetricCard` drew an empty figure. No widget key binds - * the card's figure: measured through the real `DashboardRenderer` and - * `DashboardGridLayout`, a `dataset` draws `DatasetWidget` in the card's place - * by a rule that ignores the `type`. Since objectui#11514 the TypeScript widget + * the card's figure. objectui#11483 measured, through the real + * `DashboardRenderer` and `DashboardGridLayout`, that a `dataset` then drew + * `DatasetWidget` in the card's place by a rule that ignored the `type`. Since + * objectui#11598 (2026-10-04) both surfaces read a widget key on the widget arm + * alone: a `metric-card` carrying one (`dataset`, `options`, `component`, …) is + * refused by the strict face and is not drawn as a widget — the card draws its + * own keys. Since objectui#11514 the TypeScript widget * arm, {@link DashboardWidgetSchema}'s `type`, names no member either: a slot * entry is read by the slot's element type, not by the widget arm. * diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index 38d1b51c84..3aec255042 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -1186,10 +1186,14 @@ export const DashboardWidgetLayoutSchema = z.object({ * parsed as a WIDGET, so the arm's required `value` governed only when a * card-only input happened to be present, and `MetricCard` drew an empty * figure. objectui#11483 measured, through the real `DashboardRenderer` and - * `DashboardGridLayout`, that no widget key binds the card's figure. A - * `dataset` makes the dashboard draw `DatasetWidget` in the card's place, by a - * rule that ignores the `type`. The `options` bag is spread onto the node as - * literal props, which is not a binding. So the type left this vocabulary, and + * `DashboardGridLayout`, that no widget key binds the card's figure: a + * `dataset` then drew `DatasetWidget` in the card's place, by a rule that + * ignored the `type`, and the `options` bag was spread onto the node as + * literal props, which is not a binding. Since objectui#11598 (2026-10-04) + * both surfaces read a widget key on the widget arm alone: a `metric-card` + * carrying one (`dataset`, `options`, `component`, …) is refused by the strict + * face and is not drawn as a widget — the card draws its own keys, and no + * `options` bag is spread onto it. So the type left this vocabulary, and * the component arm's required `value` is the one reading of a `metric-card` * in the slot. A dataset-bound single figure is the `metric` widget * (`type: 'metric'` with `dataset` and `values`).