From 92c2a32163d96fd984b12ea7ebc8e84fee09fd2b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 00:29:39 +0000 Subject: [PATCH 01/14] spec(types): DashboardWidgetSchema['type'] names the widget vocabulary only; a valueless metric-card is refused on the TS face The widget arm's `type` drops DashboardComponentWidgetType, so the TS face matches the Zod twin's widget vocabulary: a `metric-card` is read by the slot's component arm alone and its required `value` governs. The MEASURED LIMIT pin becomes a compile-time refusal, the KnownDrift row loses `type`, and the 7952 pin records that the component arm is no longer assignable to the widget arm. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- ...ard-widget-slot-component-arm-7952.test.ts | 24 ++++++---- .../metric-card-needs-value-11483.test.ts | 37 +++++++-------- .../src/__tests__/zod-mirror-parity.test.ts | 25 +++++----- packages/types/src/complex.ts | 47 +++++++++---------- 4 files changed, 67 insertions(+), 66 deletions(-) diff --git a/packages/types/src/__tests__/dashboard-widget-slot-component-arm-7952.test.ts b/packages/types/src/__tests__/dashboard-widget-slot-component-arm-7952.test.ts index c428306351..96821ae4dd 100644 --- a/packages/types/src/__tests__/dashboard-widget-slot-component-arm-7952.test.ts +++ b/packages/types/src/__tests__/dashboard-widget-slot-component-arm-7952.test.ts @@ -41,9 +41,12 @@ * index signature satisfies the excess-property check) while the Zod face * refuses it by name; * 5. shape identity: the slot's element type IS the two-arm union, the arm's - * `type` IS `DashboardComponentWidgetType`, and the arm is assignable to - * `DashboardWidgetSchema` — which is why every `(w: DashboardWidgetSchema)` - * callback in `plugin-dashboard` compiled unchanged; + * `type` IS `DashboardComponentWidgetType`, and the arm is NOT assignable + * to `DashboardWidgetSchema` (objectui#11514). It was until that card, + * which is why every `(w: DashboardWidgetSchema)` callback in + * `plugin-dashboard` compiled unchanged; those callbacks now read an entry + * by this element type, and the widget arm's `type` names no component + * type, so a `metric-card` with no `value` is refused on this face too; * 6. objectui#11467: the arm DECLARES `MetricCard`'s registered inputs, so the * README's `metric-card` literals compile against members rather than * against `BaseSchema`'s index signature (which objectui#8347 removes); @@ -112,10 +115,11 @@ describe('the component-node arm is declared on the TypeScript face (objectui#79 const twoArm: Equal< Element, DashboardWidgetSlotComponentSchema | DashboardWidgetSchema > = true; // The arm's `type` is the closed component set, by reference — not a copy. const closedByReference: Equal< DashboardWidgetSlotComponentSchema['type'], DashboardComponentWidgetType > = true; - // The arm is assignable to the widget type: consumers annotating a widget - // callback `(w: DashboardWidgetSchema)` keep compiling on the union. - const armAssignable: DashboardWidgetSlotComponentSchema extends DashboardWidgetSchema ? true : false = true; - expect(twoArm && closedByReference && armAssignable).toBe(true); + // objectui#11514: the arm is NOT assignable to the widget type, whose `type` + // names no component type. A consumer reads an entry by `Element` and narrows + // on `type` to reach a widget key. + const armDisjoint: DashboardWidgetSlotComponentSchema extends DashboardWidgetSchema ? false : true = true; + expect(twoArm && closedByReference && armDisjoint).toBe(true); // The runtime side of "by reference": the set the arm keys on is the one // export, and it is the set the Zod arm reads. expect(DASHBOARD_COMPONENT_WIDGET_TYPES).toContain(usage.widgets[0].type); @@ -128,9 +132,11 @@ describe('the forbidden repair did not happen — DashboardWidgetSchema is not w // inputs. The compiler's own suggestion for this line ("Did you mean to // write 'values'?") is the repair both declarations forbid; if anyone // makes it, this directive goes unused (TS2578) and `type-check` fails. + // A widget family, so `value` is the one refused key (since objectui#11514 a + // `type: 'metric-card'` is refused on this interface as well). // @ts-expect-error — TS2561: 'value' does not exist in type 'DashboardWidgetSchema'. - const widened: DashboardWidgetSchema = { type: 'metric-card', value: '1' }; - expect(widened.type).toBe('metric-card'); + const widened: DashboardWidgetSchema = { type: 'metric', value: '1' }; + expect(widened.type).toBe('metric'); }); it('the arm\'s `type` is closed', () => { 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 c2e57f7e93..55d112c907 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 @@ -41,11 +41,10 @@ * 3. no widget key stands in for `value`: a dataset-bound card and a card * whose figure sits in `options` are refused. CONTROL: the dataset-bound * single figure's own spelling, a `metric` widget, parses; - * 4. the vocabulary on both faces: no component type is a widget type, while - * the TypeScript widget interface still READS one (it is the read type of - * every slot entry); - * 5. MEASURED LIMIT of the TypeScript face, recorded two-faced: a `metric-card` - * literal with no `value` still compiles through that read type. + * 4. the vocabulary on both faces: no component type is a widget type, and + * the TypeScript widget interface's `type` names none either + * (objectui#11514): a slot entry is read by the slot's element type, so a + * `metric-card` literal with no `value` is refused by `tsc` too. * * The registration's `required` against the slot is pinned where the live * registration is: `metricCardRegisteredInputsStrictFace-11022.test.ts` in @@ -167,28 +166,26 @@ describe('objectui#11483 — no component type is a widget type', () => { } }); - it('the TypeScript twin agrees, while the widget interface still READS the component type', () => { + it('the TypeScript twin agrees, and the widget interface names no component type either', () => { // `DashboardWidgetTypeName` is the twin of `DashboardWidgetTypeSchema`: the two are disjoint // from the component set. An authoring surface typed by it cannot offer the card as a widget. const disjoint: Equal< Extract< DashboardWidgetTypeName, DashboardComponentWidgetType >, never > = true; - // `DashboardWidgetSchema['type']` is the read type of every slot entry, the component arm's - // included: the renderers annotate slot entries with that interface. - const reads: Equal< NonNullable< DashboardWidgetSchema['type'] >, DashboardWidgetTypeName | DashboardComponentWidgetType > = true; - expect([disjoint, reads]).toEqual([true, true]); + // objectui#11514: `DashboardWidgetSchema['type']` is the widget vocabulary and nothing else. + // A slot entry is read by the slot's element type, which `plugin-dashboard`'s renderers + // annotate entries with, so the widget arm no longer has to admit the component type. + const widgetArm: Equal< NonNullable< DashboardWidgetSchema['type'] >, DashboardWidgetTypeName > = true; + // The slot's element type still reads a card: its component arm names the component set. + const element: Equal< + Extract< DashboardComponentSchema['widgets'][number]['type'], DashboardComponentWidgetType >, + DashboardComponentWidgetType + > = true; + expect([disjoint, widgetArm, element]).toEqual([true, true, true]); }); -}); - -/* ── 5. the TypeScript face's measured limit ─────────────────────────────── */ -describe('MEASURED LIMIT of the TypeScript face — recorded, not a contract', () => { - it('a `metric-card` literal with no `value` compiles through the widget read type, and both Zod faces refuse it', () => { - // It matches the widget arm, whose `type` must keep the component type because - // `DashboardWidgetSchema` is also the read type of every slot entry. If tsc ever - // refuses this literal, the corner has closed: delete this constant and the notes - // on `DashboardWidgetSchema.type` and `DashboardComponentSchema.widgets` in - // `complex.ts`. ⛔ Do not add an `@ts-expect-error` to keep the file green. + it('`tsc` refuses a `metric-card` literal with no `value`, as both Zod faces do (objectui#11514)', () => { const valuelessCard: DashboardComponentSchema = { type: 'dashboard', + // @ts-expect-error -- the component arm requires `value`, and no other arm names `metric-card`. widgets: [{ type: 'metric-card', title: 'Revenue' }], }; for (const [, face] of FACES) expect(face.safeParse(valuelessCard).success).toBe(false); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index bb7014065e..f2ccc1ab0c 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -154,7 +154,12 @@ * a delta to this number; count the registry. Nothing asserts it against a written * one, so this line is prose and can rot; the pin that cannot is the one * comparing the two halves to each other. - * - **50 entries** in `KnownDrift`, **88 keys** across them — 49 / 87 until + * - **50 entries** in `KnownDrift`, **87 keys** across them — 50 / 88 until + * objectui#11514 dropped the component type from the TS + * `DashboardWidgetSchema`'s `type`, once `plugin-dashboard` read slot entries + * by the slot's element type: both faces name the same widget vocabulary, so + * `type` LEFT `complex.zod.ts#DashboardWidgetSchema`'s entry and the entry + * stays on `component` and `options`. It was 49 / 87 until * objectui#11515 registered the TS twins of three zod-only node arms. ONE * entry is NEW with one key: `views.zod.ts#DetailSectionNodeSchema`'s * `fields`, the EXPECTED DIVERGENCE on `views.zod.ts#DetailViewFieldSchema`'s @@ -2278,18 +2283,14 @@ interface KnownDrift { * excludes every `.test.ts` under `src` and stays green while `tsconfig.test.json` * reddens. * - * `type` — joined with objectui#11483, and the TS side is the WIDER one, on purpose. - * The mirror's widget vocabulary (`DashboardWidgetTypeSchema`) dropped the component - * type `metric-card`, so the slot reads a card through its component arm alone and - * the card's required `value` governs. The TS interface keeps - * `DashboardComponentWidgetType` in `type` because it is ALSO the read type of every - * `widgets[]` entry: the component arm is assignable to it, and `plugin-dashboard`'s - * renderers annotate slot entries with it. Measured on that card's branch, dropping it - * on this face breaks those renderers under `tsc`. The literal this lets compile, a - * `metric-card` with no `value`, is pinned two-faced as a measured limit in - * `metric-card-needs-value-11483.test.ts`. + * `type` LEFT under objectui#11514. It joined with objectui#11483, which dropped the + * component type `metric-card` from the mirror's widget vocabulary while the TS + * interface kept it, because the interface was also the read type of every + * `widgets[]` entry. objectui#11514 moved `plugin-dashboard`'s slot-entry reads onto + * the slot's element type and dropped the component type on this face too, so both + * faces name the same widget vocabulary and the key measures clean. */ - 'complex.zod.ts#DashboardWidgetSchema': 'component' | 'options' | 'type'; + 'complex.zod.ts#DashboardWidgetSchema': 'component' | 'options'; /** * `onChange` — RUNTIME SLOT (objectui#6124): the `filter-builder` renderer * calls it as `props.onChange` after `SchemaRenderer`'s spread. (`fields` LEFT diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index 05793fc85f..4e83c7ed09 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -2145,9 +2145,9 @@ export interface FloatingChatbotConfig { * 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`. The one place a member is still named - * beside the widget families is {@link DashboardWidgetSchema}'s `type`, which - * is the READ type of every slot entry; see that member. + * by a rule that ignores the `type`. 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. * * ⛔ CLOSED on purpose. The ruling's triage block named an open * "extension allowed" hatch as the thing to avoid: an open hatch re-creates @@ -2288,8 +2288,7 @@ export interface DashboardWidgetSchema component?: DashboardWidgetSlotComponentSchema | SchemaNode; layout?: DashboardWidgetLayout; /** - * Widget visualization type (spec shorthand format), or an objectui component - * type the widget slot holds directly. + * Widget visualization type (spec shorthand format). * * CLOSED — see {@link DashboardWidgetTypeName}. The spec's families flow in by * reference; objectui's additions are the two named, closed sets. @@ -2297,21 +2296,17 @@ export interface DashboardWidgetSchema * or a component type nothing registers all type-checked here and only * surfaced as the renderer's red OBJUI-001 panel at runtime. * - * ⚠️ WIDER than the Zod twin's `type`, on purpose (objectui#11483). The - * validator's widget arm no longer admits {@link DashboardComponentWidgetType}: - * a `metric-card` is read by the component arm alone. On this face, this - * interface is ALSO the read type of every `widgets[]` entry. The component - * arm is assignable to it, which the dashboard docs teach and - * `plugin-dashboard`'s renderers rely on: `DashboardRenderer` and - * `DashboardGridLayout` annotate slot entries with this interface. Measured on - * objectui#11483's branch, dropping the component type here breaks those - * renderers' slot-entry callbacks under `tsc`. So the type is kept here, and a - * `metric-card` literal with no `value` still compiles through this arm. That - * is a measured limit of this face, pinned two-faced in - * `__tests__/metric-card-needs-value-11483.test.ts`. The validator refuses - * the same literal on both of its faces. - */ - type?: DashboardWidgetTypeName | DashboardComponentWidgetType; + * The same set as the Zod twin's `type`: no {@link DashboardComponentWidgetType} + * (objectui#11483 on the Zod faces, objectui#11514 here). A `metric-card` is + * read by the slot's component arm alone, + * {@link DashboardWidgetSlotComponentSchema}, whose required `value` governs, + * so a `metric-card` with no `value` is refused on this face as on both Zod + * faces. This interface is the widget arm only: a `widgets[]` entry's read + * type is the slot's element type, `DashboardComponentSchema['widgets'][number]`, + * which `plugin-dashboard`'s renderers annotate entries with. Pinned in + * `__tests__/metric-card-needs-value-11483.test.ts`. + */ + type?: DashboardWidgetTypeName; /** Widget-specific configuration (spec shorthand format). Kept `unknown` — objectui * renderers pass widget-family-specific bags the spec's `options` object does not model. */ options?: unknown; @@ -2588,11 +2583,13 @@ export interface DashboardComponentSchema extends BaseSchema, Omit; /** From 6e547f9118091a29effc8ab75bc842f460e1ee7b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 00:33:21 +0000 Subject: [PATCH 02/14] spec(plugin-dashboard): slot-entry callbacks read the slot's element type; the object-chart producer names ObjectChartSchema DashboardRenderer, DashboardGridLayout, DashboardWithConfig and isLegacyRetiredWidget read a widgets[] entry by DashboardComponentSchema['widgets'][number], and onWidgetsReorder hands back the slot's own array type. A series dispatch carries SeriesChartFamily, so both object-chart producers build a node that satisfies ObjectChartSchema with no cast. Runtime is unchanged: the families set and the dispatch are the same values, and chartType is the dispatch's resolved family on both branches. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .../src/DashboardGridLayout.tsx | 40 ++++++------ .../src/DashboardRenderer.tsx | 65 +++++++++++-------- .../src/DashboardWithConfig.tsx | 28 ++++---- .../DashboardGridLayout.persistence.test.ts | 8 +-- .../DashboardWithConfig.layout-11388.test.tsx | 6 +- .../src/legacyRetiredWidget.ts | 4 +- .../plugin-dashboard/src/widgetDispatch.ts | 40 ++++++++++-- 7 files changed, 117 insertions(+), 74 deletions(-) diff --git a/packages/plugin-dashboard/src/DashboardGridLayout.tsx b/packages/plugin-dashboard/src/DashboardGridLayout.tsx index a4882af2a2..f740509ac9 100644 --- a/packages/plugin-dashboard/src/DashboardGridLayout.tsx +++ b/packages/plugin-dashboard/src/DashboardGridLayout.tsx @@ -5,11 +5,11 @@ import { cn, Card, CardHeader, CardTitle, CardContent, Button } from '@object-ui 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 type { BaseSchema, DashboardComponentSchema, ObjectChartSchema } 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, DASHBOARD_NODE_TYPES, toDashboardNodeType } from './widgetDispatch'; +import { classifyWidgetType, DASHBOARD_NODE_TYPES, toDashboardNodeType, type DashboardWidgetSlotEntry } from './widgetDispatch'; import { LEGACY_RETIRED_WIDGET_SCHEMA, isLegacyRetiredWidget } from './legacyRetiredWidget'; import { DatasetWidget } from './DatasetWidget'; import { useWidgetSubCaption } from './widgetSubCaption'; @@ -117,7 +117,7 @@ export function mergeLayoutIntoSchema( */ function buildDefaultLayouts(schema: DashboardComponentSchema): { lg: RGLLayout[] } { return { - lg: schema.widgets?.map((widget: DashboardWidgetSchema, index: number) => ({ + lg: schema.widgets?.map((widget: DashboardWidgetSlotEntry, index: number) => ({ i: widget.id || `widget-${index}`, ...completeWidgetLayout(widget.layout, {}, defaultWidgetPlacement(index)), })) || [], @@ -193,18 +193,15 @@ export const DashboardGridLayout: React.FC = ({ // re-fetches after a save, widgets are added/removed). Previously the // useState initializer ran once and the grid drifted from the schema. // - // `w` is annotated `DashboardWidgetSchema`, as `buildDefaultLayouts` above - // already is (objectui#11348). A `widgets[]` entry is either arm of a union, - // and both arms declare `layout` as the spec's `DashboardWidget` member: the - // widget arm through the spec row, the component arm - // (`DashboardWidgetSlotComponentSchema`) by reference to it, because - // `mergeLayoutIntoSchema` below writes it onto every entry, a component node - // included (objectui#11070 round 11). That arm is assignable to - // `DashboardWidgetSchema` (pinned by `@object-ui/types`' - // `dashboard-widget-slot-component-arm-7952.test.ts`), so the annotation is - // checked by the compiler rather than asserted. + // `w` is annotated by the slot's element type, as `buildDefaultLayouts` above + // is (objectui#11514). A `widgets[]` entry is either arm of a union, and both + // arms declare `layout` as the spec's `DashboardWidget` member: the widget arm + // through the spec row, the component arm (`DashboardWidgetSlotComponentSchema`) + // by reference to it, because `mergeLayoutIntoSchema` below writes it onto + // every entry, a component node included (objectui#11070 round 11). So + // `layout` reads with the spec's type off either arm. const widgetsSignature = React.useMemo( - () => JSON.stringify(schema.widgets?.map((w: DashboardWidgetSchema, i: number) => ({ + () => JSON.stringify(schema.widgets?.map((w: DashboardWidgetSlotEntry, i: number) => ({ i: w.id || `widget-${i}`, x: w.layout?.x, y: w.layout?.y, w: w.layout?.w, h: w.layout?.h, })) ?? []), @@ -248,7 +245,7 @@ export const DashboardGridLayout: React.FC = ({ setLayouts(buildDefaultLayouts(schema)); }, [schema]); - const getComponentSchema = React.useCallback((widget: DashboardWidgetSchema) => { + const getComponentSchema = React.useCallback((widget: DashboardWidgetSlotEntry) => { // 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). @@ -316,6 +313,10 @@ export const DashboardGridLayout: React.FC = ({ // (rows are raw records) and an UNGROUPED one (a single row with no // category column at all). const effectiveXAxisKey = chartCategoryKey(effectiveAggregate, xAxisKey); + // The declared node type, `ObjectChartSchema` (objectui#11514), as + // `DashboardRenderer` builds it: `chartType` is the dispatch's + // `SeriesChartFamily`, one of the families that type declares + // (objectui#11513), with no cast. return { type: 'object-chart', chartType: dispatch.chartType, @@ -331,7 +332,7 @@ export const DashboardGridLayout: React.FC = ({ isAnimationActive: false, className: "h-full", ...chartPresentation, - }; + } satisfies ObjectChartSchema; } const dataItems = Array.isArray(widgetData) ? widgetData : widgetData?.items || []; @@ -562,9 +563,10 @@ export const DashboardGridLayout: React.FC = ({ resizeConfig={{ enabled: editMode }} onLayoutChange={handleLayoutChange} > - {/* `DashboardWidgetSchema`, for the reason `widgetsSignature` states: - `title` below is declared on the widget arm only (objectui#11348). */} - {schema.widgets?.map((widget: DashboardWidgetSchema, index: number) => { + {/* The slot's element type, for the reason `widgetsSignature` states + (objectui#11514). `title` below is declared on both arms: the + widget's spec row, and the card's heading on the component arm. */} + {schema.widgets?.map((widget: DashboardWidgetSlotEntry, index: number) => { const widgetId = widget.id || `widget-${index}`; // `getComponentSchema` builds a node for `SchemaRenderer` in every // branch, but its inferred union is wider than the renderer's diff --git a/packages/plugin-dashboard/src/DashboardRenderer.tsx b/packages/plugin-dashboard/src/DashboardRenderer.tsx index eb9046b9d1..07d47713b0 100644 --- a/packages/plugin-dashboard/src/DashboardRenderer.tsx +++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx @@ -6,7 +6,7 @@ * LICENSE file in the root directory of this source tree. */ -import type { BaseSchema, DashboardComponentSchema, DashboardWidgetSchema, DataSource, ObjectChartSchema, ObjectDataTableSchema } from '@object-ui/types'; +import type { BaseSchema, DashboardComponentSchema, DataSource, ObjectChartSchema, ObjectDataTableSchema } from '@object-ui/types'; import { SchemaRenderer, useActionEngine, useObjectLabel, PageVariablesProvider, usePageVariables, useResolvedDataSource } from '@object-ui/react'; import { useObjectTranslation, useSafeTranslate, pickLocalized, useDisplayLocale } from '@object-ui/i18n'; import type { ActionDef, ActionResult, ActionContext, ModalHandler, SduiDomPassThroughKey } from '@object-ui/core'; @@ -41,7 +41,7 @@ import { } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { isObjectProvider, deriveStaticTableColumns, composeSeriesLabel } from './utils'; -import { classifyWidgetType, METRIC_LIKE_TYPES, DASHBOARD_NODE_TYPES, toDashboardNodeType } from './widgetDispatch'; +import { classifyWidgetType, METRIC_LIKE_TYPES, DASHBOARD_NODE_TYPES, toDashboardNodeType, type DashboardWidgetSlotEntry } from './widgetDispatch'; import { LEGACY_RETIRED_WIDGET_SCHEMA, isLegacyRetiredWidget } from './legacyRetiredWidget'; import { DatasetWidget } from './DatasetWidget'; import { useWidgetSubCaption } from './widgetSubCaption'; @@ -261,8 +261,13 @@ export interface DashboardRendererProps * Receives the next widgets array (with positions swapped). The parent * is expected to persist via its data adapter. When omitted, drag-and- * drop affordances are disabled even in design mode. + * + * Typed by the slot itself, `DashboardComponentSchema['widgets']`: the array + * is `schema.widgets` reordered, so each entry is a widget or a component node + * placed directly in the slot (objectui#11514; it read `DashboardWidgetSchema[]` + * while the component arm was assignable to the widget arm). */ - onWidgetsReorder?: (widgets: DashboardWidgetSchema[]) => void; + onWidgetsReorder?: (widgets: DashboardComponentSchema['widgets']) => void; /** Optional handler for actionType="modal" header actions. Receives a schema and ActionContext. */ modalHandler?: ModalHandler; /** Optional named handlers for actionType="script" header actions, keyed by action name (actionUrl). */ @@ -322,13 +327,12 @@ const DashboardRendererInner = forwardRef { if (schema.columns != null) return schema.columns; - // Typed `DashboardWidgetSchema[]`, the type `renderWidget` below already - // takes (objectui#11348). Both arms of the `widgets[]` union declare - // `layout` as the spec's `DashboardWidget` member — the component arm + // Typed by the slot's element type, the type `renderWidget` below takes + // (objectui#11514). Both arms of the `widgets[]` union declare `layout` as + // the spec's `DashboardWidget` member — the component arm // (`DashboardWidgetSlotComponentSchema`) by reference since objectui#11070 - // round 11 — and that arm is assignable to the widget arm, so this is a - // checked widening, not a cast. - const widgets: DashboardWidgetSchema[] = schema.widgets ?? []; + // round 11 — so `layout` reads with the spec's type off either arm. + const widgets: DashboardWidgetSlotEntry[] = schema.widgets ?? []; let maxSpan = 0; for (const w of widgets) { const span = (w.layout?.x ?? 0) + (w.layout?.w ?? 0); @@ -477,7 +481,7 @@ const DashboardRendererInner = forwardRef { + (widget: DashboardWidgetSlotEntry): string | undefined => { const fallback = resolveLabel(widget.title); if (localized || !dashName || !widget.id || fallback === undefined) return fallback; return widgetTitle(dashName, widget.id, fallback); @@ -486,7 +490,7 @@ const DashboardRendererInner = forwardRef { + (widget: DashboardWidgetSlotEntry): string | undefined => { const fallback = resolveLabel(widget.description); if (localized || !dashName || !widget.id) return fallback; return widgetDescription(dashName, widget.id, fallback); @@ -647,15 +651,15 @@ const DashboardRendererInner = forwardRef w.id === active.id); - const newIndex = widgets.findIndex((w: DashboardWidgetSchema) => w.id === over.id); + const oldIndex = widgets.findIndex((w: DashboardWidgetSlotEntry) => w.id === active.id); + const newIndex = widgets.findIndex((w: DashboardWidgetSlotEntry) => w.id === over.id); if (oldIndex < 0 || newIndex < 0) return; onWidgetsReorder?.(arrayMove(widgets, oldIndex, newIndex)); }, [dragEnabled, schema.widgets, onWidgetsReorder] ); - const renderWidget = (widget: DashboardWidgetSchema, index: number, forceMobileFullWidth?: boolean) => { + const renderWidget = (widget: DashboardWidgetSlotEntry, index: number, forceMobileFullWidth?: boolean) => { // Clamp widget span to grid columns to prevent overflow. A widget // with NO layout (e.g. authored in the Studio designer, which omits // it) would otherwise get a single column in the positioned grid and @@ -676,10 +680,12 @@ const DashboardRendererInner = forwardRef { @@ -771,9 +777,12 @@ const DashboardRendererInner = forwardRef (schema.widgets ?? []).map((w: DashboardWidgetSchema) => w.id).filter((id: string | undefined): id is string => !!id), + () => (schema.widgets ?? []).map((w: DashboardWidgetSlotEntry) => w.id).filter((id: string | undefined): id is string => !!id), [schema.widgets] ); const metricIds = useMemo( - () => (schema.widgets ?? []).filter((w: DashboardWidgetSchema) => w.type === 'metric').map((w: DashboardWidgetSchema) => w.id).filter((id: string | undefined): id is string => !!id), + () => (schema.widgets ?? []).filter((w: DashboardWidgetSlotEntry) => w.type === 'metric').map((w: DashboardWidgetSlotEntry) => w.id).filter((id: string | undefined): id is string => !!id), [schema.widgets] ); const otherIds = useMemo( - () => (schema.widgets ?? []).filter((w: DashboardWidgetSchema) => w.type !== 'metric').map((w: DashboardWidgetSchema) => w.id).filter((id: string | undefined): id is string => !!id), + () => (schema.widgets ?? []).filter((w: DashboardWidgetSlotEntry) => w.type !== 'metric').map((w: DashboardWidgetSlotEntry) => w.id).filter((id: string | undefined): id is string => !!id), [schema.widgets] ); if (isMobile) { // Separate metric widgets from other widgets for better mobile layout - const metricWidgets = schema.widgets?.filter((w: DashboardWidgetSchema) => w.type === 'metric') || []; - const otherWidgets = schema.widgets?.filter((w: DashboardWidgetSchema) => w.type !== 'metric') || []; + const metricWidgets = schema.widgets?.filter((w: DashboardWidgetSlotEntry) => w.type === 'metric') || []; + const otherWidgets = schema.widgets?.filter((w: DashboardWidgetSlotEntry) => w.type !== 'metric') || []; const mobileBody = (
@@ -1317,7 +1326,7 @@ const DashboardRendererInner = forwardRef 0 && (
- {metricWidgets.map((widget: DashboardWidgetSchema, index: number) => renderWidget(widget, index))} + {metricWidgets.map((widget: DashboardWidgetSlotEntry, index: number) => renderWidget(widget, index))}
)} @@ -1326,7 +1335,7 @@ const DashboardRendererInner = forwardRef 0 && (
- {otherWidgets.map((widget: DashboardWidgetSchema, index: number) => renderWidget(widget, index, true))} + {otherWidgets.map((widget: DashboardWidgetSlotEntry, index: number) => renderWidget(widget, index, true))}
)} @@ -1371,7 +1380,7 @@ const DashboardRendererInner = forwardRef - {schema.widgets?.map((widget: DashboardWidgetSchema, index: number) => renderWidget(widget, index))} + {schema.widgets?.map((widget: DashboardWidgetSlotEntry, index: number) => renderWidget(widget, index))}
); diff --git a/packages/plugin-dashboard/src/DashboardWithConfig.tsx b/packages/plugin-dashboard/src/DashboardWithConfig.tsx index e159fd06dc..1fcc1c58c7 100644 --- a/packages/plugin-dashboard/src/DashboardWithConfig.tsx +++ b/packages/plugin-dashboard/src/DashboardWithConfig.tsx @@ -10,7 +10,8 @@ import * as React from 'react'; 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 type { DashboardComponentSchema } from '@object-ui/types'; +import type { DashboardWidgetSlotEntry } from './widgetDispatch'; import { completeWidgetLayout, defaultWidgetPlacement } from '@object-ui/types'; import { DashboardRenderer } from './DashboardRenderer'; @@ -95,16 +96,15 @@ export function DashboardWithConfig({ // field change. This prevents useConfigDraft from resetting the draft. const selectedWidgetConfig = React.useMemo(() => { if (!selectedWidgetId || !liveSchema.widgets) return null; - // Read through `DashboardWidgetSchema` (objectui#11348). `title` and - // `colorVariant` below are declared on the widget arm of the `widgets[]` - // union only — the spec's `DashboardWidget` row declares both — and - // `layout` on both arms (the component arm takes the spec's member by - // reference, objectui#11070 round 11). The component arm - // (`DashboardWidgetSlotComponentSchema`) is assignable to the widget arm - // (pinned by `@object-ui/types`' - // `dashboard-widget-slot-component-arm-7952.test.ts`), so the annotation is - // checked by the compiler rather than asserted. - const widgets: DashboardWidgetSchema[] = liveSchema.widgets; + // Read through the slot's element type (objectui#11514), as every + // 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 + // (`DashboardWidgetSlotComponentSchema`) is no longer assignable to the + // widget arm, whose `type` names no component type since objectui#11514. + const widgets: DashboardWidgetSlotEntry[] = liveSchema.widgets; const index = widgets.findIndex( (w) => (w.id || w.title) === selectedWidgetId, ); @@ -168,8 +168,8 @@ export function DashboardWithConfig({ if (!prev.widgets) return prev; return { ...prev, - // `DashboardWidgetSchema`, for the reason `selectedWidgetConfig` - // states: `title` is a widget-arm key (objectui#11348). + // The slot's element type, for the reason `selectedWidgetConfig` + // states (objectui#11514). // // A slider edits ONE dimension of the spec's four-number `layout`, // so it goes through `completeWidgetLayout` (objectui#11388): on a @@ -178,7 +178,7 @@ export function DashboardWithConfig({ // 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) => { + widgets: prev.widgets.map((w: DashboardWidgetSlotEntry, index: number) => { if ((w.id || w.title) !== selectedWidgetId) return w; if (field === 'layoutW' || field === 'layoutH') { const patch = field === 'layoutW' ? { w: value } : { h: value }; diff --git a/packages/plugin-dashboard/src/__tests__/DashboardGridLayout.persistence.test.ts b/packages/plugin-dashboard/src/__tests__/DashboardGridLayout.persistence.test.ts index ac2364afed..e352086ab2 100644 --- a/packages/plugin-dashboard/src/__tests__/DashboardGridLayout.persistence.test.ts +++ b/packages/plugin-dashboard/src/__tests__/DashboardGridLayout.persistence.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { mergeLayoutIntoSchema } from '../DashboardGridLayout'; -import type { DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui/types'; +import type { DashboardComponentSchema } from '@object-ui/types'; // No dashboard-root `title`: that key was retired (objectui#7509), so it was // inert here and compiled only through `BaseSchema`'s index signature @@ -53,9 +53,9 @@ describe('mergeLayoutIntoSchema', () => { const next = mergeLayoutIntoSchema(schema, [ { i: 'widget-0', x: 2, y: 3, w: 4, h: 5 }, ]); - // Read through `DashboardWidgetSchema`, the `widgets[]` arm that declares - // `layout` (objectui#11348); the component arm is assignable to it. - const first: DashboardWidgetSchema | undefined = next.widgets?.[0]; + // Read through the slot's element type (objectui#11514): both arms declare + // `layout`, the component arm by reference to the spec's widget member. + const first: DashboardComponentSchema['widgets'][number] | undefined = next.widgets?.[0]; expect(first?.layout).toEqual({ x: 2, y: 3, w: 4, h: 5 }); }); }); diff --git a/packages/plugin-dashboard/src/__tests__/DashboardWithConfig.layout-11388.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardWithConfig.layout-11388.test.tsx index e69472799c..6fb7b0088d 100644 --- a/packages/plugin-dashboard/src/__tests__/DashboardWithConfig.layout-11388.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DashboardWithConfig.layout-11388.test.tsx @@ -85,10 +85,10 @@ function stepUp(fieldKey: 'layoutW' | 'layoutH') { fireEvent.keyDown(thumb, { key: 'ArrowRight' }); } -/** One widget from the schema the dashboard was last rendered with. */ -function liveWidget(id: string): DashboardWidgetSchema { +/** One widget from the schema the dashboard was last rendered with, read by the slot's element type (objectui#11514). */ +function liveWidget(id: string): DashboardComponentSchema['widgets'][number] { const schema = rendered.schema as DashboardComponentSchema; - const widgets: DashboardWidgetSchema[] = schema.widgets ?? []; + const widgets: DashboardComponentSchema['widgets'] = schema.widgets ?? []; return widgets.find((w) => w.id === id)!; } diff --git a/packages/plugin-dashboard/src/legacyRetiredWidget.ts b/packages/plugin-dashboard/src/legacyRetiredWidget.ts index 929872a607..0d66a8ea0c 100644 --- a/packages/plugin-dashboard/src/legacyRetiredWidget.ts +++ b/packages/plugin-dashboard/src/legacyRetiredWidget.ts @@ -42,7 +42,7 @@ * used. */ -import type { DashboardWidgetSchema } from '@object-ui/types'; +import type { DashboardWidgetSlotEntry } from './widgetDispatch'; /** * The placeholder schema rendered in place of a retired inline-analytics widget. @@ -101,7 +101,7 @@ type LegacyRetiredReadKeys = { * surface no observable behavior, and it keeps the predicate true on its own * terms for any surface that has no such fork. */ -export function isLegacyRetiredWidget(widget: DashboardWidgetSchema | null | undefined): boolean { +export function isLegacyRetiredWidget(widget: DashboardWidgetSlotEntry | null | undefined): boolean { if (!widget) return false; const w = widget as LegacyRetiredReadKeys; if (w.dataset) return false; diff --git a/packages/plugin-dashboard/src/widgetDispatch.ts b/packages/plugin-dashboard/src/widgetDispatch.ts index 3817987372..757a9ec202 100644 --- a/packages/plugin-dashboard/src/widgetDispatch.ts +++ b/packages/plugin-dashboard/src/widgetDispatch.ts @@ -23,6 +23,21 @@ * widget was never routed. */ +import type { DashboardComponentSchema } from '@object-ui/types'; + +/** + * One entry of a dashboard's `widgets[]`: the slot's element type, a widget or + * a component node placed directly in the slot (the 2026-08-14 `metric-card` + * slot ruling, objectstack#8593). Read off `DashboardComponentSchema.widgets`, + * so it is the slot's own declaration, not a restatement. + * + * Every dashboard surface reads `widgets[]` entries by this type + * (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. + */ +export type DashboardWidgetSlotEntry = DashboardComponentSchema['widgets'][number]; + /** * Spec chart families that only render as another family. Normalizing them * here is what lets one branch serve `column` and `bar` (the spec's own @@ -41,7 +56,7 @@ export const CHART_TYPE_ALIASES: Record = { }; /** Cartesian / categorical / flow families the chart renderer draws. */ -export const SERIES_CHART_TYPES: ReadonlySet = new Set([ +const SERIES_CHART_FAMILIES = [ 'bar', 'horizontal-bar', 'line', 'area', 'pie', 'donut', 'scatter', 'funnel', 'radar', 'treemap', 'sankey', // `combo` joined `ChartTypeSchema` in spec 17.0.0-rc.1. The chart renderer @@ -50,7 +65,24 @@ export const SERIES_CHART_TYPES: ReadonlySet = new Set([ // stored `combo` widget fell through to the red "Unknown component type" // panel the moment the spec started accepting it. 'combo', -]); +] as const; + +/** + * A family {@link SERIES_CHART_TYPES} names, as a literal union: the + * `chartType` a `series` dispatch carries. Each member is one of the families + * `ObjectChartSchema.chartType` declares (objectui#11513), so the `object-chart` + * node a surface builds from a dispatch names its family with no cast + * (objectui#11514). Pinned by `__tests__/dashboard-producer-node-types-11514.test.ts`. + */ +export type SeriesChartFamily = (typeof SERIES_CHART_FAMILIES)[number]; + +/** Cartesian / categorical / flow families the chart renderer draws. */ +export const SERIES_CHART_TYPES: ReadonlySet = new Set(SERIES_CHART_FAMILIES); + +/** Whether `family` is a {@link SeriesChartFamily}. */ +function isSeriesChartFamily(family: string): family is SeriesChartFamily { + return SERIES_CHART_TYPES.has(family); +} /** * Single-value "performance" families — one number, optionally against a @@ -97,7 +129,7 @@ export type WidgetDispatchFamily = export interface WidgetDispatch { family: WidgetDispatchFamily; /** For `series`: the alias-resolved base family to draw. */ - chartType?: string; + chartType?: SeriesChartFamily; } /** @@ -108,7 +140,7 @@ export interface WidgetDispatch { export function classifyWidgetType(widgetType: string | undefined): WidgetDispatch { if (!widgetType) return { family: 'passthrough' }; const resolved = CHART_TYPE_ALIASES[widgetType] ?? widgetType; - if (SERIES_CHART_TYPES.has(resolved)) return { family: 'series', chartType: resolved }; + if (isSeriesChartFamily(resolved)) return { family: 'series', chartType: resolved }; if (METRIC_LIKE_TYPES.has(widgetType)) return { family: 'metric' }; if (TABLE_LIKE_TYPES.has(widgetType)) return { family: 'table' }; if (widgetType === 'pivot') return { family: 'pivot' }; From b597df8bc7c051e18aa64a75329c49d46d339823 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 00:40:34 +0000 Subject: [PATCH 03/14] spec(app-shell,plugin-designer): read dashboard widgets[] entries by the slot's element type DashboardPreview's reorder handler takes DashboardComponentSchema['widgets'], the array DashboardRenderer's onWidgetsReorder now hands back. The designer's DashboardEditor reads an entry (WidgetCard, WidgetPropertyPanel, its preview and the measure probe) by the same element type, because the component arm is no longer assignable to the widget arm. Type-only; nothing renders differently. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .../previews/DashboardPreview.tsx | 6 +++-- .../plugin-designer/src/DashboardEditor.tsx | 25 ++++++++++++------- .../DashboardEditor.i18nTitle.test.tsx | 15 ++++++----- .../DashboardEditor.layout-11388.test.tsx | 6 ++--- .../DashboardEditor.rootTitleRetired.test.tsx | 8 +++--- 5 files changed, 34 insertions(+), 26 deletions(-) diff --git a/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.tsx index 5576cf45ac..eae5909f5d 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.tsx @@ -17,7 +17,7 @@ import * as React from 'react'; import { Loader2, Pencil, X, Check } from 'lucide-react'; -import type { DashboardWidgetSchema } from '@object-ui/types'; +import type { DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui/types'; import { useAdapter } from '../../../providers/AdapterProvider.js'; import type { MetadataPreviewProps } from '../preview-registry.js'; import { PreviewShell, PreviewErrorBoundary, PreviewMessage } from './PreviewShell.js'; @@ -76,8 +76,10 @@ export function DashboardPreview({ [onSelectionChange, widgets, locale], ); + // Typed as `DashboardRenderer`'s `onWidgetsReorder` hands it: the slot's own + // array, each entry a widget or a component node (objectui#11514). const handleReorder = React.useCallback( - (next: DashboardWidgetSchema[]) => { + (next: DashboardComponentSchema['widgets']) => { if (!onPatch) return; onPatch({ widgets: next }); }, diff --git a/packages/plugin-designer/src/DashboardEditor.tsx b/packages/plugin-designer/src/DashboardEditor.tsx index 97af5b74dc..2d09d580fc 100644 --- a/packages/plugin-designer/src/DashboardEditor.tsx +++ b/packages/plugin-designer/src/DashboardEditor.tsx @@ -78,6 +78,14 @@ export interface DashboardEditorProps { onWidgetSelect?: (widgetId: string | null) => void; } +/** + * One entry of `widgets[]`: the slot's element type, a widget or a component + * node placed directly in the slot. Every read of an entry goes through it + * (objectui#11514): the widget arm, `DashboardWidgetSchema`, names no component + * type, so the component arm is not assignable to it. + */ +type DashboardWidgetEntry = DashboardComponentSchema['widgets'][number]; + // ============================================================================ // Constants // ============================================================================ @@ -235,7 +243,7 @@ function writeWidgetTitle( * measure picker), which this package cannot import: it does not depend on * plugin-dashboard. */ -function measureRefusal(widget: DashboardWidgetSchema, type: string | undefined): string | undefined { +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') : []; const probe = { @@ -256,7 +264,7 @@ function measureRefusal(widget: DashboardWidgetSchema, type: string | undefined) // ============================================================================ interface WidgetCardProps { - widget: DashboardWidgetSchema; + widget: DashboardWidgetEntry; index: number; total: number; selected: boolean; @@ -349,7 +357,7 @@ function WidgetCard({ // ============================================================================ interface WidgetPropertyPanelProps { - widget: DashboardWidgetSchema; + widget: DashboardWidgetEntry; /** The widget's position in `widgets[]`, which the grid auto-places it by. */ index: number; readOnly: boolean; @@ -537,12 +545,11 @@ function WidgetPropertyPanel({ function DashboardPreview({ schema }: { schema: DashboardComponentSchema }) { const { t, language } = useDesignerTranslation(); - // Typed `DashboardWidgetSchema[]` (objectui#11348): the card below reads - // `title`, which is declared on the widget arm of the `widgets[]` union only - // (the spec's `DashboardWidget` row declares it). The component arm - // (`DashboardWidgetSlotComponentSchema`) is assignable to that arm, so this is - // a checked widening, not a cast — the same type `WidgetCard` already takes. - const widgets: DashboardWidgetSchema[] = schema.widgets || []; + // Typed by the slot's element type, `DashboardWidgetEntry` (objectui#11514), + // the same type `WidgetCard` takes: the card below reads `title`, which both + // arms of the `widgets[]` union declare (the widget arm's spec row, and the + // component arm's card heading). + const widgets: DashboardWidgetEntry[] = schema.widgets || []; return (
{/* diff --git a/packages/plugin-designer/src/__tests__/DashboardEditor.i18nTitle.test.tsx b/packages/plugin-designer/src/__tests__/DashboardEditor.i18nTitle.test.tsx index c74fb22891..9a546a4b2d 100644 --- a/packages/plugin-designer/src/__tests__/DashboardEditor.i18nTitle.test.tsx +++ b/packages/plugin-designer/src/__tests__/DashboardEditor.i18nTitle.test.tsx @@ -42,7 +42,7 @@ import { describe, it, expect, vi } from 'vitest'; import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; -import type { DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui/types'; +import type { DashboardComponentSchema } from '@object-ui/types'; import { DashboardEditor } from '../DashboardEditor'; const MAP_TITLE = { en: 'Pipeline', 'zh-CN': '销售漏斗' }; @@ -108,14 +108,13 @@ function lastSchema(onChange: ReturnType): DashboardComponentSchem } /** - * One widget by id, read through `DashboardWidgetSchema` (objectui#11348): a - * `widgets[]` entry is either arm of a union and `title` is declared on the - * widget arm only, which the component arm is assignable to — so the - * annotation is checked, where `.title` straight off the union element was - * reached only through `BaseSchema`'s index signature. + * One widget by id, read through the slot's element type (objectui#11514): a + * `widgets[]` entry is either arm of a union, and `title` is declared on both + * (the widget arm's spec row, and the component arm's card heading), so + * `.title` reads with a declared type off either arm. */ -function widgetById(schema: DashboardComponentSchema, id: string): DashboardWidgetSchema | undefined { - const widgets: DashboardWidgetSchema[] = schema.widgets ?? []; +function widgetById(schema: DashboardComponentSchema, id: string): DashboardComponentSchema['widgets'][number] | undefined { + const widgets: DashboardComponentSchema['widgets'] = schema.widgets ?? []; return widgets.find((w) => w.id === id); } diff --git a/packages/plugin-designer/src/__tests__/DashboardEditor.layout-11388.test.tsx b/packages/plugin-designer/src/__tests__/DashboardEditor.layout-11388.test.tsx index a5bf33b797..dbe55dc6b7 100644 --- a/packages/plugin-designer/src/__tests__/DashboardEditor.layout-11388.test.tsx +++ b/packages/plugin-designer/src/__tests__/DashboardEditor.layout-11388.test.tsx @@ -46,11 +46,11 @@ function openPanelFor(widgets: DashboardWidgetSchema[], id: string) { return onChange; } -/** One widget from the schema of the most recent `onChange` call. */ -function changedWidget(onChange: ReturnType, id: string): DashboardWidgetSchema { +/** One widget from the schema of the most recent `onChange` call, read by the slot's element type (objectui#11514). */ +function changedWidget(onChange: ReturnType, id: string): DashboardComponentSchema['widgets'][number] { const calls = onChange.mock.calls; const schema = calls[calls.length - 1][0] as DashboardComponentSchema; - const widgets: DashboardWidgetSchema[] = schema.widgets ?? []; + const widgets: DashboardComponentSchema['widgets'] = schema.widgets ?? []; return widgets.find((w) => w.id === id)!; } diff --git a/packages/plugin-designer/src/__tests__/DashboardEditor.rootTitleRetired.test.tsx b/packages/plugin-designer/src/__tests__/DashboardEditor.rootTitleRetired.test.tsx index 4dcf213639..836ae61754 100644 --- a/packages/plugin-designer/src/__tests__/DashboardEditor.rootTitleRetired.test.tsx +++ b/packages/plugin-designer/src/__tests__/DashboardEditor.rootTitleRetired.test.tsx @@ -29,7 +29,7 @@ import * as React from 'react'; import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, screen, fireEvent, cleanup, within } from '@testing-library/react'; -import type { DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui/types'; +import type { DashboardComponentSchema } from '@object-ui/types'; import { DashboardEditor } from '../DashboardEditor'; vi.mock('@object-ui/plugin-grid', async (importOriginal) => ({ @@ -122,9 +122,9 @@ describe('DashboardEditor — the root `title` read arm is retired (objectui#750 // false (objectui#9513). const calls = onChange.mock.calls; const next = calls[calls.length - 1][0] as DashboardComponentSchema; - // Read through `DashboardWidgetSchema`, the `widgets[]` arm that declares - // the widget `title` (objectui#11348); the component arm is assignable to it. - const widgets: DashboardWidgetSchema[] = next.widgets ?? []; + // Read through the slot's element type (objectui#11514): both arms of the + // `widgets[]` union declare `title`. + const widgets: DashboardComponentSchema['widgets'] = next.widgets ?? []; expect(widgets[0].title).toBe('Net Revenue'); }); }); From d746b8887cb08febdb1c74bcccda7b590dfab128 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 00:45:26 +0000 Subject: [PATCH 04/14] docs(plugin-dashboard): read a widget key off widgets[] by narrowing the entry on type The README and the plugin page taught reading entries through DashboardWidgetSchema because the component arm was assignable to it. It no longer is, so both blocks narrow an entry on `type` (`metric-card` is the one component type the slot holds) and read the widget key off the widget arm, which the compiler checks. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- content/docs/plugins/plugin-dashboard.mdx | 18 +++++++++++------- packages/plugin-dashboard/README.md | 17 ++++++++++------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/content/docs/plugins/plugin-dashboard.mdx b/content/docs/plugins/plugin-dashboard.mdx index 91afff17b6..8ef24ddc2f 100644 --- a/content/docs/plugins/plugin-dashboard.mdx +++ b/content/docs/plugins/plugin-dashboard.mdx @@ -488,18 +488,22 @@ component node placed directly in the slot (`DashboardWidgetSlotComponentSchema` The component arm declares the card's registered inputs — the keys above. The widget keys (`colorVariant`, `filter`, `dataset`, …) are declared on the widget arm, which takes them from the spec's `DashboardWidget` row, and on no member of -the component arm. To read one off `widgets[]`, read it through -`DashboardWidgetSchema`. The component arm is assignable to that type, so the -annotation is checked rather than asserted, and the key gets its declared type -instead of the `any` the component arm's passthrough supplies: +the component arm. An entry's own type is the slot's element type, +`DashboardComponentSchema['widgets'][number]`. The component arm is not +assignable to `DashboardWidgetSchema`, whose `type` names no component type, so +narrow an entry on `type` before reading a widget key. `metric-card` is the one +component type the slot holds; any other entry is the widget arm, the compiler +checks the narrowing, and the key gets its declared type instead of the `any` +the component arm's passthrough supplies: ```ts -import type { DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui/types'; +import type { DashboardComponentSchema } from '@object-ui/types'; declare const dashboard: DashboardComponentSchema; -const widgets: DashboardWidgetSchema[] = dashboard.widgets; -const accents = widgets.map((w) => w.colorVariant ?? 'default'); +const accents = dashboard.widgets.map((w) => + w.type === 'metric-card' ? 'default' : (w.colorVariant ?? 'default'), +); ``` `title` and `layout` are the exceptions: both arms declare them. `title` is the diff --git a/packages/plugin-dashboard/README.md b/packages/plugin-dashboard/README.md index fcb54dee31..2bcfb6d129 100644 --- a/packages/plugin-dashboard/README.md +++ b/packages/plugin-dashboard/README.md @@ -670,18 +670,21 @@ The widget keys (`colorVariant`, `filter`, `dataset`, …) are declared on the widget arm, `DashboardWidgetSchema`, which takes them from the spec's `DashboardWidget` row. The component arm declares none of them. Read straight off a `widgets[]` entry, such a key is typed `any`, supplied by the component -arm's passthrough. Read it through `DashboardWidgetSchema` instead, which is how -this package's own readers do it. The component arm is assignable to that type, -so the annotation is checked by the compiler, not asserted, and the key gets its -declared type: +arm's passthrough. This package's own readers take an entry by the slot's +element type, `DashboardComponentSchema['widgets'][number]`. The component arm +is not assignable to `DashboardWidgetSchema`, whose `type` names no component +type, so narrow an entry on `type` first: `metric-card` is the one component +type the slot holds, and any other entry is the widget arm. The compiler checks +the narrowing, not an annotation, and the key gets its declared type: ```typescript -import type { DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui/types'; +import type { DashboardComponentSchema } from '@object-ui/types'; declare const dashboard: DashboardComponentSchema; -const widgets: DashboardWidgetSchema[] = dashboard.widgets; -const accents = widgets.map((w) => w.colorVariant ?? 'default'); +const accents = dashboard.widgets.map((w) => + w.type === 'metric-card' ? 'default' : (w.colorVariant ?? 'default'), +); ``` `title` and `layout` are the two widget keys both arms declare. `title` is the From bc1a344c178007e8408f4fe989d62d6cf7c6588b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 00:46:53 +0000 Subject: [PATCH 05/14] test(plugin-dashboard): pin the object-chart producers against ObjectChartSchema on both faces A type row holds SeriesChartFamily inside ObjectChartSchema['chartType'] and the slot-entry and reorder types to the slot's own; a runtime row renders every series family through both surfaces and has the Zod ObjectChartSchema judge the node each one hands the renderer. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- ...shboard-producer-node-types-11514.test.tsx | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 packages/plugin-dashboard/src/__tests__/dashboard-producer-node-types-11514.test.tsx diff --git a/packages/plugin-dashboard/src/__tests__/dashboard-producer-node-types-11514.test.tsx b/packages/plugin-dashboard/src/__tests__/dashboard-producer-node-types-11514.test.tsx new file mode 100644 index 0000000000..1c694d71e7 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/dashboard-producer-node-types-11514.test.tsx @@ -0,0 +1,134 @@ +/** + * 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#11514 — the dashboard's `object-chart` producers name the node type + * they build, and every dashboard surface reads a `widgets[]` entry by the + * slot's element type. + * + * ## What is pinned + * + * 1. TYPE LEVEL: a `series` dispatch carries `SeriesChartFamily`, and every + * member is a family `ObjectChartSchema.chartType` declares + * (objectui#11513), so both producers build an `object-chart` node that + * `satisfies ObjectChartSchema` with no cast. The slot-entry read type is + * the slot's own element type, and `onWidgetsReorder` hands back the + * slot's own array type. + * 2. RUNTIME: for every family the dispatch routes as a series, the + * `object-chart` node each surface hands the renderer is accepted by the + * Zod face of that node, `ObjectChartSchema` in `@object-ui/types/zod`, + * and carries the dispatched family as its `chartType`. + * + * The node is read the way `DashboardChart.countSeriesKey-8266.test.tsx` reads + * it: a recorder registered for `object-chart` receives the node from the real + * render path of each surface. + */ + +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { ComponentRegistry } from '@object-ui/core'; +import '@object-ui/components'; +// Registers the real `object-chart` entry this file then overrides, so the +// override is measured against the production registration order. +import '@object-ui/plugin-charts'; +import '../index'; +import { DashboardRenderer, type DashboardRendererProps } from '../DashboardRenderer'; +import { DashboardGridLayout } from '../DashboardGridLayout'; +import { + classifyWidgetType, + SERIES_CHART_TYPES, + type DashboardWidgetSlotEntry, + type SeriesChartFamily, + type WidgetDispatch, +} from '../widgetDispatch'; +import type { DashboardComponentSchema, DataSource, ObjectChartSchema } from '@object-ui/types'; +import { ObjectChartSchema as ObjectChartZod } from '@object-ui/types/zod'; + +type Equal< A, B > = + (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; + +/* ── 1. type level ───────────────────────────────────────────────────────── */ + +describe('objectui#11514 — the producers\' declared types', () => { + it('every series family is a family `ObjectChartSchema.chartType` declares', () => { + const declared: SeriesChartFamily extends NonNullable< ObjectChartSchema['chartType'] > ? true : false = true; + const dispatched: Equal< WidgetDispatch['chartType'], SeriesChartFamily | undefined > = true; + expect([declared, dispatched]).toEqual([true, true]); + }); + + it('a slot entry is read by the slot\'s element type, and the reorder callback takes the slot\'s array', () => { + const entry: Equal< DashboardWidgetSlotEntry, DashboardComponentSchema['widgets'][number] > = true; + const reorder: Equal< + Parameters< NonNullable< DashboardRendererProps['onWidgetsReorder'] > >[0], + DashboardComponentSchema['widgets'] + > = true; + expect([entry, reorder]).toEqual([true, true]); + }); +}); + +/* ── 2. runtime: the Zod face judges the node each surface builds ────────── */ + +/** Every `object-chart` node a surface handed the renderer, in order. */ +const composed: any[] = []; +const recorder = (props: any) => { + composed.push(props.schema ?? props); + return null; +}; +ComponentRegistry.register('object-chart', recorder as any, { + namespace: 'test', + label: 'recorder', + category: 'plugin', +} as any); + +afterEach(cleanup); + +/** A stub carrying only the members the path calls (see the 8266 file's NOTE on objectui#7912). */ +const dataSource = { aggregate: async () => [], find: async () => [] }; + +const composeVia = async (surface: 'grid' | 'renderer', widget: Record) => { + composed.length = 0; + render( + + {surface === 'grid' ? ( + + ) : ( + + )} + , + ); + await waitFor(() => expect(composed.length).toBeGreaterThan(0)); + const node = composed[composed.length - 1]; + cleanup(); + return node; +}; + +const FAMILIES = [...SERIES_CHART_TYPES]; +const SURFACES = ['grid', 'renderer'] as const; + +describe.each(SURFACES)('%s surface — the object-chart node is the declared node (objectui#11514)', (surface) => { + it('routes a non-empty set of series families (non-vacuity)', () => { + expect(FAMILIES.length).toBeGreaterThan(0); + for (const family of FAMILIES) expect(classifyWidgetType(family).family).toBe('series'); + }); + + it.each(FAMILIES)('`%s`: the Zod face accepts the node, and its chartType is the dispatched family', async (family) => { + const node = await composeVia(surface, { + id: 'w1', + type: family, + title: 'Cases', + options: { xField: 'status' }, + data: { provider: 'object', object: 'crm_case', aggregate: { function: 'count', groupBy: 'status' } }, + }); + expect(node.type).toBe('object-chart'); + expect(node.chartType).toBe(classifyWidgetType(family).chartType); + const verdict = ObjectChartZod.safeParse(node); + expect(verdict.success, verdict.success ? '' : JSON.stringify(verdict.error.issues)).toBe(true); + }); +}); From e602b4ea763fbc58653c8199098bb151a9212acd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 00:49:15 +0000 Subject: [PATCH 06/14] chore(changeset): objectui#11514 entries, and dated notes on four pending entries the narrowing makes false New: @object-ui/types minor (the widget arm's type), @object-ui/plugin-dashboard minor (onWidgetsReorder's parameter type), @object-ui/plugin-designer and @object-ui/app-shell patch (type-only reads). Dated, append-only notes on 11483-metric-card-needs-value, 7952-dashboard-widgets-component-arm, 11348-dashboard-widget-reads and dashboard-widget-type-closed-enum. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .changeset/11348-dashboard-widget-reads.md | 2 ++ .changeset/11483-metric-card-needs-value.md | 2 ++ .../11514-app-shell-dashboard-preview-reorder.md | 5 +++++ .changeset/11514-dashboard-slot-entry-types.md | 10 ++++++++++ .changeset/11514-designer-dashboard-entry-type.md | 5 +++++ .changeset/11514-types-widget-arm-type.md | 12 ++++++++++++ .changeset/7952-dashboard-widgets-component-arm.md | 2 ++ .changeset/dashboard-widget-type-closed-enum.md | 2 ++ 8 files changed, 40 insertions(+) create mode 100644 .changeset/11514-app-shell-dashboard-preview-reorder.md create mode 100644 .changeset/11514-dashboard-slot-entry-types.md create mode 100644 .changeset/11514-designer-dashboard-entry-type.md create mode 100644 .changeset/11514-types-widget-arm-type.md diff --git a/.changeset/11348-dashboard-widget-reads.md b/.changeset/11348-dashboard-widget-reads.md index 1ba8def033..c4736caced 100644 --- a/.changeset/11348-dashboard-widget-reads.md +++ b/.changeset/11348-dashboard-widget-reads.md @@ -30,3 +30,5 @@ literal is unchanged. ⚠️ **Dated note, 2026-10-01 — the component arm now declares `layout` — objectui#11070.** "the component arm has no spec row and declares none of them" above held when this change landed. Later in this same release, round 11 of objectui#11070 declared `layout` on the component arm, by reference to the spec's widget `layout`, because Save Layout writes it onto every `widgets[]` entry. `title` and `colorVariant` are still declared on the widget arm only. `.changeset/11070-dashboard-keys-round11.md` states what ships; the text above is kept as the reading of this change. ⚠️ **Dated note, 2026-10-02 — the component arm now declares `title` — objectui#11467.** "`title` and `colorVariant` are still declared on the widget arm only" in the note above held when that note was written. Later in this same release, objectui#11467 declared `metric-card`'s registered inputs on the component arm, `title` among them as the card's heading, typed as `MetricCard` reads it. So `title` is declared on both arms, and it reads as `string | I18nLabel` straight off a `widgets[]` entry. `colorVariant` is still declared on the widget arm only. `.changeset/11467-metric-card-arm-inputs.md` states what ships; the text above is kept as the reading of this change. + +⚠️ **Dated note, 2026-10-03 — the read sites move to the slot's element type — objectui#11514.** At this change, the `layout` / `title` / `colorVariant` sites above read a `widgets[]` entry through `DashboardWidgetSchema`, and the component arm was assignable to it. Now `DashboardWidgetSchema['type']` names no component type, so the component arm is not assignable to it, and the sites in `DashboardGridLayout`, `DashboardRenderer`, `DashboardWithConfig` and the designer's `DashboardEditor` read an entry by the slot's element type, `DashboardComponentSchema['widgets'][number]`. `layout` and `title` read with their declared types off either arm; `colorVariant` is still declared on the widget arm only. `.changeset/11514-dashboard-slot-entry-types.md` states what ships. The rest of this entry is kept as the reading of this change. diff --git a/.changeset/11483-metric-card-needs-value.md b/.changeset/11483-metric-card-needs-value.md index 49843a3e74..9d3989aec6 100644 --- a/.changeset/11483-metric-card-needs-value.md +++ b/.changeset/11483-metric-card-needs-value.md @@ -12,3 +12,5 @@ A `metric-card` in a dashboard's widget slot parses only with its `value` (objec - **What does not move.** On the TypeScript face, `DashboardWidgetSchema['type']` still includes `'metric-card'`. That interface is also the read type of every `widgets[]` entry, and the component arm stays assignable to it. So a `metric-card` literal with no `value` still compiles directly in `widgets[]`, while the validator refuses it on both faces. A `metric-card` in a widget's legacy `component` envelope is judged as before: there, the tolerant face's `BaseSchema` fallback still accepts a card that fails the arm. Every `metric-card` in the schema catalog, the READMEs and the docs fences carries its `value`. The one docs example that did not, under "TypeScript Support" in `content/docs/plugins/plugin-dashboard.mdx`, is rewritten. **Fix:** give the card its `value`. For a figure queried from a dataset, write a `metric` widget (`{ type: 'metric', dataset, values: [measure] }`), which the dashboard draws as the same dataset tile. + +⚠️ **Dated note, 2026-10-03 — the TypeScript widget arm drops the component type too — objectui#11514.** At this change, "What does not move" above held: `DashboardWidgetSchema['type']` still included `'metric-card'`, the interface was the read type of every `widgets[]` entry, the component arm was assignable to it, and a `metric-card` literal with no `value` still compiled directly in `widgets[]`. Now `DashboardWidgetSchema['type']` is `DashboardWidgetTypeName` alone, `plugin-dashboard`, `plugin-designer` and `app-shell` read an entry by the slot's element type (`DashboardComponentSchema['widgets'][number]`), the component arm is no longer assignable to the widget arm, and `tsc` refuses a `metric-card` with no `value` as both validator faces do. `.changeset/11514-types-widget-arm-type.md` states what ships. The rest of this entry is kept as the reading of this change. diff --git a/.changeset/11514-app-shell-dashboard-preview-reorder.md b/.changeset/11514-app-shell-dashboard-preview-reorder.md new file mode 100644 index 0000000000..52eeebcb05 --- /dev/null +++ b/.changeset/11514-app-shell-dashboard-preview-reorder.md @@ -0,0 +1,5 @@ +--- +'@object-ui/app-shell': patch +--- + +The metadata-admin dashboard preview's reorder handler takes `DashboardComponentSchema['widgets']`, the array `DashboardRenderer`'s `onWidgetsReorder` now hands back (objectui#11514). Type-only: the preview patches the same reordered array as before. diff --git a/.changeset/11514-dashboard-slot-entry-types.md b/.changeset/11514-dashboard-slot-entry-types.md new file mode 100644 index 0000000000..cce9702923 --- /dev/null +++ b/.changeset/11514-dashboard-slot-entry-types.md @@ -0,0 +1,10 @@ +--- +'@object-ui/plugin-dashboard': minor +--- + +`DashboardRenderer`'s `onWidgetsReorder` hands back the slot's own array type, `DashboardComponentSchema['widgets']`, instead of `DashboardWidgetSchema[]`, and the dashboard's `object-chart` producers name `ObjectChartSchema` (objectui#11514). `minor`, per this repository's version alignment: a reorder handler typed `(widgets: DashboardWidgetSchema[]) => void` stops compiling, because the component arm of a `widgets[]` entry is no longer assignable to `DashboardWidgetSchema`. Type the handler's parameter as `DashboardComponentSchema['widgets']`. + +- **Slot-entry reads.** `DashboardRenderer`, `DashboardGridLayout`, `DashboardWithConfig` and the retired-widget detector read a `widgets[]` entry by the slot's element type, `DashboardComponentSchema['widgets'][number]`, which is what lets `@object-ui/types` drop the component type from `DashboardWidgetSchema['type']`. +- **The `object-chart` producers.** A series dispatch carries the family as a literal union whose members are all families `ObjectChartSchema.chartType` declares, so the node both surfaces build for a `provider: 'object'` series widget satisfies `ObjectChartSchema` with no cast. +- **README.** "Reading a widget key off `widgets[]`" narrows an entry on `type` before reading a widget key, instead of annotating the array as `DashboardWidgetSchema[]`. +- **What does not move.** Nothing renders differently: the dispatch routes the same families, and each `object-chart` node carries the same `chartType` it carried before. diff --git a/.changeset/11514-designer-dashboard-entry-type.md b/.changeset/11514-designer-dashboard-entry-type.md new file mode 100644 index 0000000000..8997ca5229 --- /dev/null +++ b/.changeset/11514-designer-dashboard-entry-type.md @@ -0,0 +1,5 @@ +--- +'@object-ui/plugin-designer': patch +--- + +The dashboard editor reads a `widgets[]` entry by the slot's element type, `DashboardComponentSchema['widgets'][number]`, in its widget card, its property panel, its preview and its measure probe (objectui#11514). `@object-ui/types` no longer lets the slot's component arm stand in for `DashboardWidgetSchema`, and these reads annotated entries with that interface. Type-only: nothing the editor renders, offers or writes changes. diff --git a/.changeset/11514-types-widget-arm-type.md b/.changeset/11514-types-widget-arm-type.md new file mode 100644 index 0000000000..bfdcb4da3f --- /dev/null +++ b/.changeset/11514-types-widget-arm-type.md @@ -0,0 +1,12 @@ +--- +'@object-ui/types': minor +--- + +`DashboardWidgetSchema['type']` names the widget vocabulary only, `DashboardWidgetTypeName`: it drops `DashboardComponentWidgetType` (objectui#11514). This narrows the TypeScript face. `minor`, per this repository's version alignment; the narrowing is the breaking part. + +**Clause-②: no (narrowing)** — nothing the TypeScript face refused is accepted now, and no validator face moves. + +- **What changed.** The widget arm's `type` is the same set as its zod twin's, `DashboardWidgetTypeSchema`, which dropped the component type in objectui#11483. The TypeScript interface kept it then because it was also the read type of every `widgets[]` entry. `@object-ui/plugin-dashboard`, `@object-ui/plugin-designer` and `@object-ui/app-shell` now read an entry by the slot's element type, `DashboardComponentSchema['widgets'][number]`, so the widget arm no longer has to admit `metric-card`. +- **What now refuses that did not.** `tsc` refuses a `metric-card` with no `value` directly in `widgets[]`, as both validator faces already did: only the component arm, `DashboardWidgetSlotComponentSchema`, names `metric-card`, and its `value` is required. Assigning `type: 'metric-card'` to a `DashboardWidgetSchema` is a compile error. The component arm is no longer assignable to `DashboardWidgetSchema`, so a callback annotated `(w: DashboardWidgetSchema)` over `schema.widgets`, or a `DashboardWidgetSchema[]` annotation on it, stops compiling. +- **Fix.** Annotate an entry with `DashboardComponentSchema['widgets'][number]`. To read a widget key with its declared type, narrow the entry on `type` first: `metric-card` is the one component type the slot holds, and any other entry is the widget arm. +- **What does not move.** No zod schema, validator verdict, export name or runtime behaviour. `DASHBOARD_COMPONENT_WIDGET_TYPES` still lists `metric-card` as the component arm's `type`. diff --git a/.changeset/7952-dashboard-widgets-component-arm.md b/.changeset/7952-dashboard-widgets-component-arm.md index 42b36e9a91..cb4ada1f23 100644 --- a/.changeset/7952-dashboard-widgets-component-arm.md +++ b/.changeset/7952-dashboard-widgets-component-arm.md @@ -11,3 +11,5 @@ **What still refuses.** A widget that names a spec-family `type` and carries an undeclared key (`{ type: 'bar', bogus: 1 }`) is still a `tsc` error: the literal is discriminated by `type`, so the passthrough arm never applies to it. A `type` outside both vocabularies is refused as before. The one corner the TypeScript union cannot discriminate — a legacy `component` envelope with NO `type` plus an undeclared key — compiles on the TypeScript face and is refused by name at validation, as every `BaseSchema` slot already behaves. **Consumers.** The new arm is assignable to `DashboardWidgetSchema`, so code that annotates a widget callback `(w: DashboardWidgetSchema)` keeps compiling unchanged. Code that reads a property off an unannotated element of `schema.widgets` now sees the union, and through `BaseSchema`'s index signature that read is `any` rather than the widget's declared type — annotate the parameter to keep the narrower type. + +⚠️ **Dated note, 2026-10-03 — the component arm is no longer assignable to the widget arm — objectui#11514.** At this change, "**Consumers.**" above held: the new arm was assignable to `DashboardWidgetSchema`, so a widget callback annotated `(w: DashboardWidgetSchema)` kept compiling. Now `DashboardWidgetSchema['type']` names no component type, so the component arm is not assignable to it, and such a callback over `schema.widgets` is a compile error. Annotate an entry with the slot's element type, `DashboardComponentSchema['widgets'][number]`, and narrow it on `type` to read a widget key with its declared type. `.changeset/11514-types-widget-arm-type.md` states what ships. The rest of this entry is kept as the reading of this change. diff --git a/.changeset/dashboard-widget-type-closed-enum.md b/.changeset/dashboard-widget-type-closed-enum.md index 57d84e892c..06d37d86a9 100644 --- a/.changeset/dashboard-widget-type-closed-enum.md +++ b/.changeset/dashboard-widget-type-closed-enum.md @@ -13,3 +13,5 @@ It is now the CLOSED `DashboardWidgetTypeName` / `DashboardWidgetTypeSchema`: th Three drifts the closure surfaced and this change fixes: the dashboard designer's palette offered `grid`, which is not a widget family in either contract and was refused at publish; the metadata-admin widget inspector and the designer both wrote an unvalidated `string` from their select boxes; and a `@object-ui/types` fixture pinned `bar-chart`, a `plugin-charts` component type, on a dataset-bound widget that could never render as one. ⚠️ **Dated note, 2026-10-02 — `metric-card` is no widget type — objectui#11483.** At this change, `DashboardWidgetTypeName` / `DashboardWidgetTypeSchema` were the spec's families plus two objectui sets, `DASHBOARD_WIDGET_TYPE_EXTENSIONS` and `DASHBOARD_COMPONENT_WIDGET_TYPES`; now they are the spec's families plus `DASHBOARD_WIDGET_TYPE_EXTENSIONS` only. A `metric-card` in the widget slot is read by the slot's component arm alone, which requires its `value`. `DASHBOARD_COMPONENT_WIDGET_TYPES` still lists `metric-card` as that arm's `type`, and `DashboardWidgetSchema['type']` on the TypeScript face still reads it. `.changeset/11483-metric-card-needs-value.md` states what ships. The rest of this entry is kept as the reading of this change. + +⚠️ **Dated note, 2026-10-03 — the TypeScript widget arm no longer reads `metric-card` — objectui#11514.** At the 2026-10-02 note above, `DashboardWidgetSchema['type']` on the TypeScript face still read `metric-card`, because that interface was the read type of every `widgets[]` entry. Now it is `DashboardWidgetTypeName` alone: an entry is read by the slot's element type, and only the component arm names `metric-card`. `.changeset/11514-types-widget-arm-type.md` states what ships. The rest of this entry is kept as the reading of this change. From 7b11ac1b56176440a40af3c45dc8689f71b7d1be Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 01:03:18 +0000 Subject: [PATCH 07/14] spec(plugin-dashboard): the static chart branch carries the dispatched family as its chartType Inside the series branch the dispatch's chartType is set, so the value is the one the removed resolvedWidgetType held; it now types as SeriesChartFamily, as DashboardGridLayout's twin already did, instead of the slot entry's widened type. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- packages/plugin-dashboard/src/DashboardRenderer.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/plugin-dashboard/src/DashboardRenderer.tsx b/packages/plugin-dashboard/src/DashboardRenderer.tsx index 07d47713b0..379da5fe08 100644 --- a/packages/plugin-dashboard/src/DashboardRenderer.tsx +++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx @@ -722,7 +722,6 @@ const DashboardRendererInner = forwardRef Date: Sat, 3 Oct 2026 01:36:29 +0000 Subject: [PATCH 08/14] fix(plugin-dashboard): a typeless widget draws as the spec's default `metric`; an unknown type draws the labelled unsupported placeholder (objectui#11514, Q2 A) Both dashboard surfaces resolve an absent widget `type` to the spec's default, read from @objectstack/spec's DashboardWidget.type rather than restated, for the dispatch, the metric span and chrome, and the mobile metric row. The slot-component passthrough serves the component arm alone; any other entry that reaches it names no family and draws the labelled placeholder an unsupported family draws, shared now as unsupportedWidgetSchema, instead of the registry's OBJUI-001 panel. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .../src/DashboardGridLayout.tsx | 42 ++++++----- .../src/DashboardRenderer.tsx | 49 +++++++------ .../plugin-dashboard/src/widgetDispatch.ts | 71 ++++++++++++++++++- 3 files changed, 124 insertions(+), 38 deletions(-) diff --git a/packages/plugin-dashboard/src/DashboardGridLayout.tsx b/packages/plugin-dashboard/src/DashboardGridLayout.tsx index f740509ac9..033685251d 100644 --- a/packages/plugin-dashboard/src/DashboardGridLayout.tsx +++ b/packages/plugin-dashboard/src/DashboardGridLayout.tsx @@ -9,7 +9,15 @@ import type { BaseSchema, DashboardComponentSchema, ObjectChartSchema } from '@o import { completeWidgetLayout, defaultWidgetPlacement } from '@object-ui/types'; import { chartCategoryKey, chartConfigPresentation, chartMeasureKey } from '@object-ui/core'; import { isObjectProvider, deriveStaticTableColumns, composeSeriesLabel } from './utils'; -import { classifyWidgetType, DASHBOARD_NODE_TYPES, toDashboardNodeType, type DashboardWidgetSlotEntry } from './widgetDispatch'; +import { + classifyWidgetType, + DASHBOARD_NODE_TYPES, + isSlotComponentEntry, + resolveWidgetType, + toDashboardNodeType, + unsupportedWidgetSchema, + type DashboardWidgetSlotEntry, +} from './widgetDispatch'; import { LEGACY_RETIRED_WIDGET_SCHEMA, isLegacyRetiredWidget } from './legacyRetiredWidget'; import { DatasetWidget } from './DatasetWidget'; import { useWidgetSubCaption } from './widgetSubCaption'; @@ -261,7 +269,9 @@ export const DashboardGridLayout: React.FC = ({ // branches are what swallow it. if (isLegacyRetiredWidget(widget)) return LEGACY_RETIRED_WIDGET_SCHEMA; - const widgetType = widget.type; + // The authored `type`, or the spec's default (`metric`) when the entry + // names none (objectui#11514, Q2 A), as `DashboardRenderer` resolves it. + const widgetType = resolveWidgetType(widget); const options = (widget.options || {}) as Record; // One shared classification (./widgetDispatch) — this surface used to name // 8 chart families by hand while DatasetWidget covered all 19, so radar / @@ -462,21 +472,21 @@ export const DashboardGridLayout: React.FC = ({ } if (dispatch.family === 'unsupported') { - return { - type: 'text', - content: `「${widgetType}」chart type is not supported yet`, - variant: 'caption', - align: 'center', - className: 'flex h-full w-full items-center justify-center rounded border border-dashed bg-muted/20 p-4 text-muted-foreground', - }; + return unsupportedWidgetSchema(widgetType); } - // The slot-component passthrough takes the namespaced node key too - // (`toDashboardNodeType`, objectui#10859 batch 8). - return toDashboardNodeType({ - ...widget, - ...options - }); + // 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 + }); + } + return unsupportedWidgetSchema(widgetType); }, [resolveSeriesLabel]); return ( @@ -613,7 +623,7 @@ export const DashboardGridLayout: React.FC = ({ // the value — so it must take the shared Card wrapper to get a title // and border like its neighbours, instead of showing as bare text // (`DashboardRenderer.tsx:777-782`, same rule, same reason). - const isSelfContained = widget.type === 'metric' && !datasetBound; + const isSelfContained = resolveWidgetType(widget) === 'metric' && !datasetBound; // `DashboardWidget.title` is the spec's `I18nLabel`: since // 17.0.0-rc.6 an author may inline a per-locale map // (`{ en: 'Pipeline', 'zh-CN': '销售漏斗' }`) instead of a string. diff --git a/packages/plugin-dashboard/src/DashboardRenderer.tsx b/packages/plugin-dashboard/src/DashboardRenderer.tsx index 379da5fe08..089ed91a56 100644 --- a/packages/plugin-dashboard/src/DashboardRenderer.tsx +++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx @@ -41,7 +41,7 @@ import { } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { isObjectProvider, deriveStaticTableColumns, composeSeriesLabel } from './utils'; -import { classifyWidgetType, METRIC_LIKE_TYPES, DASHBOARD_NODE_TYPES, toDashboardNodeType, type DashboardWidgetSlotEntry } from './widgetDispatch'; +import { classifyWidgetType, METRIC_LIKE_TYPES, DASHBOARD_NODE_TYPES, toDashboardNodeType, resolveWidgetType, isSlotComponentEntry, unsupportedWidgetSchema, type DashboardWidgetSlotEntry } from './widgetDispatch'; import { LEGACY_RETIRED_WIDGET_SCHEMA, isLegacyRetiredWidget } from './legacyRetiredWidget'; import { DatasetWidget } from './DatasetWidget'; import { useWidgetSubCaption } from './widgetSubCaption'; @@ -669,7 +669,10 @@ const DashboardRendererInner = forwardRef; // Renderer-internal data sources only (ADR-0021): the inline // `options.data` / `widget.data` array, or the `provider: 'object'` @@ -971,22 +974,25 @@ const DashboardRendererInner = forwardRef (schema.widgets ?? []).filter((w: DashboardWidgetSlotEntry) => w.type === 'metric').map((w: DashboardWidgetSlotEntry) => w.id).filter((id: string | undefined): id is string => !!id), + () => (schema.widgets ?? []).filter((w: DashboardWidgetSlotEntry) => resolveWidgetType(w) === 'metric').map((w: DashboardWidgetSlotEntry) => w.id).filter((id: string | undefined): id is string => !!id), [schema.widgets] ); const otherIds = useMemo( - () => (schema.widgets ?? []).filter((w: DashboardWidgetSlotEntry) => w.type !== 'metric').map((w: DashboardWidgetSlotEntry) => w.id).filter((id: string | undefined): id is string => !!id), + () => (schema.widgets ?? []).filter((w: DashboardWidgetSlotEntry) => resolveWidgetType(w) !== 'metric').map((w: DashboardWidgetSlotEntry) => w.id).filter((id: string | undefined): id is string => !!id), [schema.widgets] ); if (isMobile) { // Separate metric widgets from other widgets for better mobile layout - const metricWidgets = schema.widgets?.filter((w: DashboardWidgetSlotEntry) => w.type === 'metric') || []; - const otherWidgets = schema.widgets?.filter((w: DashboardWidgetSlotEntry) => w.type !== 'metric') || []; + // A typeless widget is a `metric` (the spec's default, objectui#11514). + const metricWidgets = schema.widgets?.filter((w: DashboardWidgetSlotEntry) => resolveWidgetType(w) === 'metric') || []; + const otherWidgets = schema.widgets?.filter((w: DashboardWidgetSlotEntry) => resolveWidgetType(w) !== 'metric') || []; const mobileBody = (
diff --git a/packages/plugin-dashboard/src/widgetDispatch.ts b/packages/plugin-dashboard/src/widgetDispatch.ts index 757a9ec202..2346bec826 100644 --- a/packages/plugin-dashboard/src/widgetDispatch.ts +++ b/packages/plugin-dashboard/src/widgetDispatch.ts @@ -23,7 +23,14 @@ * widget was never routed. */ -import type { DashboardComponentSchema } from '@object-ui/types'; +import { DASHBOARD_COMPONENT_WIDGET_TYPES } from '@object-ui/types'; +import type { + DashboardComponentSchema, + DashboardWidgetSlotComponentSchema, + DashboardWidgetTypeName, + TextSchema, +} from '@object-ui/types'; +import { DashboardWidgetSchema as SpecDashboardWidgetSchema } from '@objectstack/spec/ui'; /** * One entry of a dashboard's `widgets[]`: the slot's element type, a widget or @@ -38,6 +45,68 @@ import type { DashboardComponentSchema } from '@object-ui/types'; */ export type DashboardWidgetSlotEntry = DashboardComponentSchema['widgets'][number]; +/** + * The widget `type` the spec resolves an ABSENT one to — READ FROM THE SPEC, + * never restated: `@objectstack/spec`'s `DashboardWidget.type` is + * `ChartTypeSchema.default(...)`, so parsing an absent `type` through that + * member returns the spec's own answer (`metric` at 17.5.0). The same reading + * `@object-ui/core`'s dashboard filters take of the spec's `dateRange` default. + * + * objectui's zod mirror strips imported defaults, so a typeless widget reaches + * the dashboard surfaces unresolved; this is where they resolve it + * (objectui#11514, Q2 A, 「协议为基准」). Computed on first use: the spec's + * schemas are lazy. + */ +let specWidgetTypeDefault: DashboardWidgetTypeName | undefined; +export function specDefaultWidgetType(): DashboardWidgetTypeName { + if (specWidgetTypeDefault === undefined) { + specWidgetTypeDefault = SpecDashboardWidgetSchema.shape.type.parse(undefined); + } + return specWidgetTypeDefault; +} + +/** + * The `type` a dashboard surface draws a `widgets[]` entry as: the authored + * `type`, or {@link specDefaultWidgetType} when the entry names none + * (objectui#11514, Q2 A). Every read of an entry's `type` on the two surfaces + * goes through it — the dispatch, the metric span and chrome, the mobile + * metric row — so a typeless widget draws exactly as the same widget with + * `type: 'metric'` does. It used to reach the slot-component passthrough and + * draw the registry's red OBJUI-001 panel. + */ +export function resolveWidgetType(entry: DashboardWidgetSlotEntry): NonNullable { + return entry.type ?? specDefaultWidgetType(); +} + +/** + * Whether a slot entry is the component arm, `DashboardWidgetSlotComponentSchema`: + * its `type` is a member of the closed `DASHBOARD_COMPONENT_WIDGET_TYPES`, which + * no widget `type` names (objectui#11483). Read at runtime, so a stored entry + * whose `type` is a string outside every vocabulary is NOT the component arm. + */ +export function isSlotComponentEntry(entry: DashboardWidgetSlotEntry): entry is DashboardWidgetSlotComponentSchema { + return (DASHBOARD_COMPONENT_WIDGET_TYPES as readonly unknown[]).includes(entry.type); +} + +/** + * The labelled placeholder a dashboard surface draws for a widget `type` it + * has no renderer for: a known family with no renderer yet + * (`UNSUPPORTED_CHART_TYPES`), and, since objectui#11514 (Q2 A), a `type` that + * names no family at all — stale metadata both validator faces refuse at + * `type`, which used to fall through to the slot-component passthrough and + * draw the registry's red OBJUI-001 panel dumping the widget. One declaration + * for both surfaces, which each spelled it out before. + */ +export function unsupportedWidgetSchema(widgetType: string) { + return { + type: 'text', + content: `「${widgetType}」chart type is not supported yet`, + variant: 'caption', + align: 'center', + className: 'flex h-full w-full items-center justify-center rounded border border-dashed bg-muted/20 p-4 text-muted-foreground', + } as const satisfies TextSchema; +} + /** * Spec chart families that only render as another family. Normalizing them * here is what lets one branch serve `column` and `bar` (the spec's own From 332b423fb73842b503e51c7e13554f1c766182a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 01:37:52 +0000 Subject: [PATCH 09/14] fix(plugin-dashboard): the dataset path draws a typeless widget as the spec's `metric` too; pin both Q2 A renders on both surfaces (objectui#11514) DatasetWidget resolves an absent widget `type` to the spec's default, so a typeless dataset-bound widget with a dimension draws the metric tile instead of a bar chart. The new pin holds, on both surfaces, that a typeless widget draws byte-for-byte what `type: 'metric'` draws (inline, dataset-bound and on the renderer's mobile layout), that an unknown `type` draws the labelled placeholder a known unsupported family draws, and that a metric-card still passes through the component arm. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .../plugin-dashboard/src/DatasetWidget.tsx | 9 +- ...et-type-default-and-unknown-11514.test.tsx | 148 ++++++++++++++++++ 2 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 packages/plugin-dashboard/src/__tests__/widget-type-default-and-unknown-11514.test.tsx diff --git a/packages/plugin-dashboard/src/DatasetWidget.tsx b/packages/plugin-dashboard/src/DatasetWidget.tsx index 222f90aa79..a27ff8c340 100644 --- a/packages/plugin-dashboard/src/DatasetWidget.tsx +++ b/packages/plugin-dashboard/src/DatasetWidget.tsx @@ -95,6 +95,7 @@ import { useFilterScope } from '@object-ui/react'; import { resolveFilterPlaceholders, computeMetricDelta } from './utils'; import { metricAccentTextClass } from './colorVariants'; import { DrillDownDrawer } from './DrillDownDrawer'; +import { specDefaultWidgetType } from './widgetDispatch'; type Row = Record; // One RESULT-side totals grouping (the response's `totals[]`), named apart from @@ -517,7 +518,9 @@ export function DatasetWidget({ widget, dataSource, subCaption }: { widget: any; const datasetName = String(widget?.dataset ?? ''); const dimensions: string[] = useMemo(() => (Array.isArray(widget?.dimensions) ? widget.dimensions.filter(Boolean) : []), [widget]); const values: string[] = useMemo(() => (Array.isArray(widget?.values) ? widget.values.filter(Boolean) : []), [widget]); - const widgetType = String(widget?.type ?? ''); + // An absent `type` is the spec's default, `metric` (objectui#11514, Q2 A), + // as the two dashboard surfaces resolve it before they reach this widget. + const widgetType = String(widget?.type ?? specDefaultWidgetType()); const isTable = widgetType === 'table' || widgetType === 'pivot'; // ── Which widgets are a one-number tile (objectui#11261) ───────────────── // By TYPE, only the metric family. By SHAPE, a widget that declares no @@ -536,8 +539,8 @@ export function DatasetWidget({ widget, dataSource, subCaption }: { widget: any; // `MEASURE_AXIS_CHART_FAMILIES` for why. // // Read off `CHART_TYPE_MAP` directly, never through `chartType`'s `?? 'bar'` - // default: a widget with no `type` resolves to `metric` in the spec, and the - // default would route it to a bar chart. + // default: that default belongs to a type the map does not name. A widget with + // no `type` is already `metric` here (above), so it is a tile by type. const rendersEveryMeasureWithoutDimension = values.length > 1 && (isTable || MEASURE_AXIS_CHART_FAMILIES.has(CHART_TYPE_MAP[widgetType] ?? '')); const isMetric = METRIC_TYPES.has(widgetType) || (dimensions.length === 0 && !rendersEveryMeasureWithoutDimension); diff --git a/packages/plugin-dashboard/src/__tests__/widget-type-default-and-unknown-11514.test.tsx b/packages/plugin-dashboard/src/__tests__/widget-type-default-and-unknown-11514.test.tsx new file mode 100644 index 0000000000..26b857c1fa --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/widget-type-default-and-unknown-11514.test.tsx @@ -0,0 +1,148 @@ +/** + * 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#11514, Q2 A — what a dashboard draws for a widget whose `type` is + * absent, and for one whose `type` names nothing. + * + * ## The ruling + * + * 「协议为基准」: `@objectstack/spec`'s `DashboardWidget.type` defaults to + * `metric`, so an absent `type` IS a `metric` widget, on both dashboard + * surfaces. A runtime `type` that names no family and no component type is + * stale metadata both validator faces refuse at `type`; it draws the labelled + * placeholder an unsupported family draws. The slot-component passthrough + * then serves the component arm alone. + * + * Before this change, both widgets fell through to the passthrough and drew + * the registry's red "Unknown component type" (OBJUI-001) panel; a typeless + * dataset-bound widget with a dimension drew a bar chart. + * + * ## What is pinned, on both surfaces + * + * 1. a typeless widget draws byte-for-byte what the same widget with + * `type: 'metric'` draws (the control), inline and dataset-bound, and on + * `DashboardRenderer`'s mobile layout, which groups metric widgets; + * 2. an unknown `type` draws the labelled placeholder, worded as the + * control, a known unsupported family (`heatmap`), words it; + * 3. CONTROL for the passthrough: a `metric-card` in the slot still draws + * the card, through the component arm. + * + * Neither case draws the OBJUI-001 panel. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import '@object-ui/components'; +import '@object-ui/plugin-charts'; +import '../index'; +import { DashboardRenderer } from '../DashboardRenderer'; +import { DashboardGridLayout } from '../DashboardGridLayout'; +import { specDefaultWidgetType } from '../widgetDispatch'; +import type { DashboardComponentSchema } from '@object-ui/types'; + +afterEach(cleanup); + +/** Stored metadata, as a renderer receives it: no validator ran on it. */ +const dash = (widgets: Record[]): DashboardComponentSchema => + ({ type: 'dashboard', widgets }) as unknown as DashboardComponentSchema; + +const SURFACES = ['grid', 'renderer'] as const; +type Surface = (typeof SURFACES)[number]; + +const mount = (surface: Surface, widgets: Record[], dataSource?: unknown) => + render( + surface === 'grid' + ? + : , + ).container; + +/** The markup a surface draws once it has settled, unmounted afterwards. */ +const drawn = async (surface: Surface, widgets: Record[], ready: RegExp, dataSource?: unknown) => { + const container = mount(surface, widgets, dataSource); + await waitFor(() => expect(container.textContent).toMatch(ready)); + const html = container.innerHTML; + cleanup(); + return html; +}; + +const UNKNOWN_TYPE_PANEL = /Unknown component type/; + +describe('the spec\'s default widget type is read from the spec', () => { + it('is `metric` at the installed spec', () => { + expect(specDefaultWidgetType()).toBe('metric'); + }); +}); + +describe.each(SURFACES)('%s surface — an absent `type` is `metric` (objectui#11514, Q2 A)', (surface) => { + const inline = { id: 'w1', title: 'Revenue', options: { value: 7 } }; + + it('an inline typeless widget draws what `type: \'metric\'` draws', async () => { + const typeless = await drawn(surface, [inline], /7/); + const control = await drawn(surface, [{ ...inline, type: 'metric' }], /7/); + expect(typeless).toBe(control); + expect(typeless).not.toMatch(UNKNOWN_TYPE_PANEL); + }); + + it('a dataset-bound typeless widget with a dimension draws the metric tile, as `type: \'metric\'` does', async () => { + const source = () => ({ + queryDataset: vi.fn(async () => ({ + rows: [{ status: 'Open', invoice_count: 42 }], + fields: [ + { name: 'status', type: 'string', label: 'Status' }, + { name: 'invoice_count', type: 'number', label: 'Invoices' }, + ], + })), + }); + const bound = { id: 'w1', title: 'Invoices', dataset: 'invoices', dimensions: ['status'], values: ['invoice_count'] }; + const typeless = await drawn(surface, [bound], /42/, source()); + const control = await drawn(surface, [{ ...bound, type: 'metric' }], /42/, source()); + expect(typeless).toBe(control); + }); +}); + +describe('renderer surface, mobile layout — a typeless widget sits in the metric row', () => { + it('draws what `type: \'metric\'` draws', async () => { + const width = window.innerWidth; + Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 500 }); + try { + const widgets = (type?: string) => [ + { id: 'w1', title: 'Revenue', options: { value: 7 }, ...(type ? { type } : {}) }, + { id: 'w2', type: 'table', title: 'Rows', options: { data: [{ name: 'A' }] } }, + ]; + const typeless = await drawn('renderer', widgets(), /7/); + const control = await drawn('renderer', widgets('metric'), /7/); + expect(typeless).toBe(control); + // Non-vacuity: the mobile layout is the one drawn (its metric row is a 2-column grid). + expect(typeless).toContain('grid-cols-2'); + } finally { + Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: width }); + } + }); +}); + +describe.each(SURFACES)('%s surface — an unknown `type` draws the labelled placeholder (objectui#11514, Q2 A)', (surface) => { + it('draws the placeholder, not the unknown-type panel', async () => { + const container = mount(surface, [{ id: 'w2', type: 'gauge2', title: 'Mystery', options: { value: 1 } }]); + await waitFor(() => expect(container.textContent).toContain('「gauge2」chart type is not supported yet')); + expect(container.textContent).not.toMatch(UNKNOWN_TYPE_PANEL); + }); + + it('CONTROL: a known unsupported family draws the same placeholder', async () => { + const container = mount(surface, [{ id: 'w2', type: 'heatmap', title: 'Heat', options: { value: 1 } }]); + await waitFor(() => expect(container.textContent).toContain('「heatmap」chart type is not supported yet')); + }); + + it('CONTROL: a `metric-card` in the slot still draws the card through the component arm', async () => { + const container = mount(surface, [{ id: 'w3', type: 'metric-card', title: 'Card', value: '$5' }]); + await waitFor(() => expect(container.textContent).toContain('$5')); + expect(container.textContent).not.toMatch(/not supported yet/); + expect(container.textContent).not.toMatch(UNKNOWN_TYPE_PANEL); + }); +}); From ef25db8b0ebd0062cd70595468b01d82600970cc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 01:45:51 +0000 Subject: [PATCH 10/14] spec(plugin-dashboard): read an entry's legacy component envelope as the widget arm declares it, so the producers' return is no longer any On the slot entry union, a bare entry.component reads the component arm's index signature and is `any`, which made both surfaces' getComponentSchema return `any`: the SchemaRenderer casts were casts of `any`, and dropping them would have compiled for the wrong reason, under V1 too. entryComponent reads the same value, typed DashboardWidgetSchema['component']. The cast comment in DashboardGridLayout now names what the casts still narrow. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .../src/DashboardGridLayout.tsx | 19 ++++++++++--------- .../src/DashboardRenderer.tsx | 5 +++-- .../plugin-dashboard/src/widgetDispatch.ts | 13 +++++++++++++ 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/packages/plugin-dashboard/src/DashboardGridLayout.tsx b/packages/plugin-dashboard/src/DashboardGridLayout.tsx index 033685251d..912c2a9f08 100644 --- a/packages/plugin-dashboard/src/DashboardGridLayout.tsx +++ b/packages/plugin-dashboard/src/DashboardGridLayout.tsx @@ -12,6 +12,7 @@ import { isObjectProvider, deriveStaticTableColumns, composeSeriesLabel } from ' import { classifyWidgetType, DASHBOARD_NODE_TYPES, + entryComponent, isSlotComponentEntry, resolveWidgetType, toDashboardNodeType, @@ -257,7 +258,8 @@ export const DashboardGridLayout: React.FC = ({ // 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). - if (widget.component) return toDashboardNodeType(widget.component); + const authoredComponent = entryComponent(widget); + if (authoredComponent) return toDashboardNodeType(authoredComponent); // Retired legacy inline-analytics widget (framework#3320) — the SAME // detector `DashboardRenderer` uses, imported rather than restated @@ -579,14 +581,13 @@ export const DashboardGridLayout: React.FC = ({ {schema.widgets?.map((widget: DashboardWidgetSlotEntry, index: number) => { const widgetId = widget.id || `widget-${index}`; // `getComponentSchema` builds a node for `SchemaRenderer` in every - // branch, but its inferred union is wider than the renderer's - // declared input: the passthrough fallback spreads a - // `DashboardWidgetSchema` whose `type` is OPTIONAL, and the metric - // branches carry `widget.title`'s `I18nLabel` where `BaseSchema` - // declares a plain `string`. Both are pre-existing looseness in the - // widget types rather than anything this call site can state - // truthfully, so the narrowing is named here once (objectui#4548) - // instead of being spread across the two render sites below. + // branch, but not every branch's node is a declared type yet: the + // flat `object-metric` node has none (objectui#11525), the + // `plugin-dashboard:metric` key is typed `string` + // (`DASHBOARD_NODE_TYPES`, objectui#11466), and the static `pivot` + // does not state `PivotTableSchema`'s required axes. So the + // narrowing is named here once (objectui#4548) instead of being + // spread across the two render sites below. const componentSchema = getComponentSchema(widget) as BaseSchema | string | null | undefined; // ADR-0021 — a widget bound to a semantic-layer dataset renders // through the governed queryDataset path (DatasetWidget) instead of diff --git a/packages/plugin-dashboard/src/DashboardRenderer.tsx b/packages/plugin-dashboard/src/DashboardRenderer.tsx index 089ed91a56..f298e066a7 100644 --- a/packages/plugin-dashboard/src/DashboardRenderer.tsx +++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx @@ -41,7 +41,7 @@ import { } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { isObjectProvider, deriveStaticTableColumns, composeSeriesLabel } from './utils'; -import { classifyWidgetType, METRIC_LIKE_TYPES, DASHBOARD_NODE_TYPES, toDashboardNodeType, resolveWidgetType, isSlotComponentEntry, unsupportedWidgetSchema, type DashboardWidgetSlotEntry } from './widgetDispatch'; +import { classifyWidgetType, METRIC_LIKE_TYPES, DASHBOARD_NODE_TYPES, toDashboardNodeType, resolveWidgetType, isSlotComponentEntry, unsupportedWidgetSchema, entryComponent, type DashboardWidgetSlotEntry } from './widgetDispatch'; import { LEGACY_RETIRED_WIDGET_SCHEMA, isLegacyRetiredWidget } from './legacyRetiredWidget'; import { DatasetWidget } from './DatasetWidget'; import { useWidgetSubCaption } from './widgetSubCaption'; @@ -695,7 +695,8 @@ const DashboardRendererInner = forwardRef Date: Sat, 3 Oct 2026 01:46:28 +0000 Subject: [PATCH 11/14] docs(plugin-dashboard): a widget with no `type` draws as `metric`; an unknown `type` draws the labelled placeholder Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- content/docs/plugins/plugin-dashboard.mdx | 16 ++++++++++++++++ packages/plugin-dashboard/README.md | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/content/docs/plugins/plugin-dashboard.mdx b/content/docs/plugins/plugin-dashboard.mdx index 8295030d6f..04a2eeaa6f 100644 --- a/content/docs/plugins/plugin-dashboard.mdx +++ b/content/docs/plugins/plugin-dashboard.mdx @@ -392,6 +392,22 @@ from widget `options`. In particular, on a dataset-bound gauge: derived measure to the dataset (`derived: { op: 'ratio', … }`) and bind the widget to it. +### A widget with no `type`, or a `type` that names no family + +A widget that declares no `type` is a `metric` widget. `@objectstack/spec`'s +`DashboardWidget.type` defaults to `metric`, and both dashboard surfaces +(`DashboardRenderer` and `DashboardGridLayout`) read that default from the spec, +so the widget draws exactly as the same widget with `type: 'metric'` does, +inline or bound to a dataset (objectui#11514). objectui's validator accepts the +widget without a `type` and does not write the default in, so the surfaces are +where it resolves. + +A `type` that names no widget family and no component type (a typo, or a family +the spec no longer has) is refused by both validator faces at `type`. A stored +one draws the labelled placeholder "「type」chart type is not supported yet", as +a known family with no renderer (`heatmap`) does, instead of the renderer's red +"Unknown component type" panel. + ### How many measures a widget renders A dataset-bound widget queries every measure in `values`. What it renders diff --git a/packages/plugin-dashboard/README.md b/packages/plugin-dashboard/README.md index 2bcfb6d129..fdd34e082e 100644 --- a/packages/plugin-dashboard/README.md +++ b/packages/plugin-dashboard/README.md @@ -521,6 +521,22 @@ the enum gets no accent and is not aliased to a nearby colour: it is invalid metadata, rejected where it is authored and published rather than reinterpreted here. +## A widget with no `type`, or a `type` that names no family + +A widget that declares no `type` is a `metric` widget. `@objectstack/spec`'s +`DashboardWidget.type` defaults to `metric`, and both dashboard surfaces +(`DashboardRenderer` and `DashboardGridLayout`) read that default from the spec, +so the widget draws exactly as the same widget with `type: 'metric'` does, +inline or bound to a dataset (objectui#11514). objectui's validator accepts the +widget without a `type` and does not write the default in, so the surfaces are +where it resolves. + +A `type` that names no widget family and no component type (a typo, or a family +the spec no longer has) is refused by both validator faces at `type`. A stored +one draws the labelled placeholder "「type」chart type is not supported yet", as +a known family with no renderer (`heatmap`) does, instead of the renderer's red +"Unknown component type" panel. + ## How many measures a widget renders A dataset-bound widget queries every measure in `values`. What it renders From f1dd8b5fb7d352c36c93b2c31a59c7f84968b78a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 01:47:01 +0000 Subject: [PATCH 12/14] chore(changeset): the plugin-dashboard entry states the typeless and unknown-type renders (objectui#11514, Q2 A) Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .changeset/11514-dashboard-slot-entry-types.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.changeset/11514-dashboard-slot-entry-types.md b/.changeset/11514-dashboard-slot-entry-types.md index cce9702923..304dabd95d 100644 --- a/.changeset/11514-dashboard-slot-entry-types.md +++ b/.changeset/11514-dashboard-slot-entry-types.md @@ -2,9 +2,12 @@ '@object-ui/plugin-dashboard': minor --- -`DashboardRenderer`'s `onWidgetsReorder` hands back the slot's own array type, `DashboardComponentSchema['widgets']`, instead of `DashboardWidgetSchema[]`, and the dashboard's `object-chart` producers name `ObjectChartSchema` (objectui#11514). `minor`, per this repository's version alignment: a reorder handler typed `(widgets: DashboardWidgetSchema[]) => void` stops compiling, because the component arm of a `widgets[]` entry is no longer assignable to `DashboardWidgetSchema`. Type the handler's parameter as `DashboardComponentSchema['widgets']`. +`DashboardRenderer`'s `onWidgetsReorder` hands back the slot's own array type, `DashboardComponentSchema['widgets']`, instead of `DashboardWidgetSchema[]`; the dashboard's `object-chart` producers name `ObjectChartSchema`; and a widget with no `type`, or with a `type` that names nothing, draws what the spec says it is (objectui#11514). `minor`, per this repository's version alignment: a reorder handler typed `(widgets: DashboardWidgetSchema[]) => void` stops compiling, because the component arm of a `widgets[]` entry is no longer assignable to `DashboardWidgetSchema`. Type the handler's parameter as `DashboardComponentSchema['widgets']`. +- **A widget with no `type` is a `metric` widget.** `@objectstack/spec`'s `DashboardWidget.type` defaults to `metric`; both surfaces read that default from the spec and draw the widget exactly as the same widget with `type: 'metric'`: inline, bound to a dataset (where a typeless widget with a dimension drew a bar chart), and in `DashboardRenderer`'s mobile metric row. It used to reach the slot-component passthrough and draw the registry's red "Unknown component type" (OBJUI-001) panel. +- **A `type` that names no family** (and no component type) draws the labelled placeholder "「type」chart type is not supported yet", as a known family with no renderer does, instead of that red panel. Both validator faces already refuse such a widget at `type`. +- **The slot-component passthrough** serves the slot's component arm (`metric-card`) alone, unchanged for it. - **Slot-entry reads.** `DashboardRenderer`, `DashboardGridLayout`, `DashboardWithConfig` and the retired-widget detector read a `widgets[]` entry by the slot's element type, `DashboardComponentSchema['widgets'][number]`, which is what lets `@object-ui/types` drop the component type from `DashboardWidgetSchema['type']`. - **The `object-chart` producers.** A series dispatch carries the family as a literal union whose members are all families `ObjectChartSchema.chartType` declares, so the node both surfaces build for a `provider: 'object'` series widget satisfies `ObjectChartSchema` with no cast. -- **README.** "Reading a widget key off `widgets[]`" narrows an entry on `type` before reading a widget key, instead of annotating the array as `DashboardWidgetSchema[]`. -- **What does not move.** Nothing renders differently: the dispatch routes the same families, and each `object-chart` node carries the same `chartType` it carried before. +- **README.** "Reading a widget key off `widgets[]`" narrows an entry on `type` before reading a widget key, and a new section states what a widget with no `type`, or an unknown one, draws. +- **What does not move.** Every widget that names a known family draws as before: the dispatch routes the same families, and each `object-chart` node carries the same `chartType` it carried. From 16d2d8e20298a8e85bd806a579649478e64275ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 01:54:53 +0000 Subject: [PATCH 13/14] fix(plugin-dashboard): a typeless `component` envelope keeps its chrome; its primitives bridge through toRenderableSchema (objectui#11514) The spec's default widget `type` (`metric`) is a statement about the spec's widget. objectui's legacy `component` envelope has no member there, draws its node rather than a family, and so is not given the default: a typeless envelope keeps `undefined` from `resolveWidgetType` and draws with its card chrome, as before. The envelope's `component` is `SchemaNode` (number and boolean members the renderer's prop does not declare); both surfaces now forward it through `toRenderableSchema`, which draws the same text the renderer drew for them bare. Pinned per surface: a number and `true` draw their text under the widget heading. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .../src/DashboardGridLayout.tsx | 7 ++-- .../src/DashboardRenderer.tsx | 9 +++-- ...shboard-producer-node-types-11514.test.tsx | 35 +++++++++++++++++++ .../plugin-dashboard/src/widgetDispatch.ts | 13 +++++-- 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/packages/plugin-dashboard/src/DashboardGridLayout.tsx b/packages/plugin-dashboard/src/DashboardGridLayout.tsx index 912c2a9f08..e8e219eec9 100644 --- a/packages/plugin-dashboard/src/DashboardGridLayout.tsx +++ b/packages/plugin-dashboard/src/DashboardGridLayout.tsx @@ -3,7 +3,7 @@ import { ResponsiveGridLayout, useContainerWidth, type LayoutItem as RGLLayout, import 'react-grid-layout/css/styles.css'; import { cn, Card, CardHeader, CardTitle, CardContent, Button } from '@object-ui/components'; import { Edit, GripVertical, Save, X, RefreshCw } from 'lucide-react'; -import { SchemaRenderer, useHasDndProvider, useDnd } from '@object-ui/react'; +import { SchemaRenderer, toRenderableSchema, useHasDndProvider, useDnd } from '@object-ui/react'; import { useObjectTranslation, useObjectLabel, useSafeTranslate, pickLocalized } from '@object-ui/i18n'; import type { BaseSchema, DashboardComponentSchema, ObjectChartSchema } from '@object-ui/types'; import { completeWidgetLayout, defaultWidgetPlacement } from '@object-ui/types'; @@ -259,7 +259,10 @@ export const DashboardGridLayout: React.FC = ({ // spelling except a `metric` / `metric-card` node key, which moves onto its // namespaced registration (`toDashboardNodeType`, objectui#10859 batch 8). const authoredComponent = entryComponent(widget); - if (authoredComponent) return toDashboardNodeType(authoredComponent); + // `toRenderableSchema` (objectui#4622) bridges the envelope's `SchemaNode` + // to what `SchemaRenderer` takes: a number or boolean draws the same text + // (or nothing, when falsy) it drew when handed to the renderer bare. + if (authoredComponent) return toRenderableSchema(toDashboardNodeType(authoredComponent)); // Retired legacy inline-analytics widget (framework#3320) — the SAME // detector `DashboardRenderer` uses, imported rather than restated diff --git a/packages/plugin-dashboard/src/DashboardRenderer.tsx b/packages/plugin-dashboard/src/DashboardRenderer.tsx index f298e066a7..bc22f1333f 100644 --- a/packages/plugin-dashboard/src/DashboardRenderer.tsx +++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx @@ -7,7 +7,7 @@ */ import type { BaseSchema, DashboardComponentSchema, DataSource, ObjectChartSchema, ObjectDataTableSchema } from '@object-ui/types'; -import { SchemaRenderer, useActionEngine, useObjectLabel, PageVariablesProvider, usePageVariables, useResolvedDataSource } from '@object-ui/react'; +import { SchemaRenderer, toRenderableSchema, useActionEngine, useObjectLabel, PageVariablesProvider, usePageVariables, useResolvedDataSource } from '@object-ui/react'; import { useObjectTranslation, useSafeTranslate, pickLocalized, useDisplayLocale } from '@object-ui/i18n'; import type { ActionDef, ActionResult, ActionContext, ModalHandler, SduiDomPassThroughKey } from '@object-ui/core'; import { @@ -672,7 +672,7 @@ const DashboardRendererInner = forwardRef { + // The envelope's `component` is `SchemaNode`, which admits numbers and booleans + // the renderer's prop does not; the producer bridges them through + // `toRenderableSchema`, which draws the same text the renderer drew for them bare. + // Only a truthy `component` is an envelope on either surface (a falsy one falls + // through to the `type` dispatch), so the two rows are the truthy primitives. + // A typeless envelope keeps its card chrome: the spec's default `type` is not + // applied to it (`resolveWidgetType`), so the heading is drawn. + const mountWidget = (component: unknown) => + render( + + {surface === 'grid' ? ( + + ) : ( + + )} + , + ).container; + + it('a number draws its text', async () => { + const container = mountWidget(5); + await waitFor(() => expect(container.textContent).toContain('Envelope')); + expect(container.textContent).toContain('5'); + }); + + it('`true` draws its text', async () => { + const container = mountWidget(true); + await waitFor(() => expect(container.textContent).toContain('Envelope')); + expect(container.textContent).toContain('true'); + expect(container.textContent).not.toMatch(/Unknown component type/); + }); +}); diff --git a/packages/plugin-dashboard/src/widgetDispatch.ts b/packages/plugin-dashboard/src/widgetDispatch.ts index 286c057e05..7f60e1c686 100644 --- a/packages/plugin-dashboard/src/widgetDispatch.ts +++ b/packages/plugin-dashboard/src/widgetDispatch.ts @@ -74,9 +74,16 @@ export function specDefaultWidgetType(): DashboardWidgetTypeName { * metric row — so a typeless widget draws exactly as the same widget with * `type: 'metric'` does. It used to reach the slot-component passthrough and * draw the registry's red OBJUI-001 panel. + * + * ⛔ Not for the legacy `component` envelope (`{ id, component, layout }`, + * objectui's own format, which the spec's widget has no member for). It draws + * its node, never a family, so the spec's default `type` says nothing about it: + * an envelope that names no `type` keeps `undefined` here and draws, chrome + * included, as it always did. */ -export function resolveWidgetType(entry: DashboardWidgetSlotEntry): NonNullable { - return entry.type ?? specDefaultWidgetType(); +export function resolveWidgetType(entry: DashboardWidgetSlotEntry): DashboardWidgetSlotEntry['type'] { + if (entry.type !== undefined) return entry.type; + return entryComponent(entry) ? undefined : specDefaultWidgetType(); } /** @@ -110,7 +117,7 @@ export function isSlotComponentEntry(entry: DashboardWidgetSlotEntry): entry is * draw the registry's red OBJUI-001 panel dumping the widget. One declaration * for both surfaces, which each spelled it out before. */ -export function unsupportedWidgetSchema(widgetType: string) { +export function unsupportedWidgetSchema(widgetType: string | undefined) { return { type: 'text', content: `「${widgetType}」chart type is not supported yet`, From 2fb1b92b6cb3ede36b700592e3a6d5eabbc690de Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 01:56:01 +0000 Subject: [PATCH 14/14] docs(plugin-dashboard): a typeless `component` envelope is not given the spec's default `type` (objectui#11514) The README, the docs page and the plugin-dashboard changeset state that the legacy envelope keeps its card heading when it names no `type`, and that a number or `true` in its `component` draws the same text through `toRenderableSchema`. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .changeset/11514-dashboard-slot-entry-types.md | 2 +- content/docs/plugins/plugin-dashboard.mdx | 4 ++++ packages/plugin-dashboard/README.md | 4 ++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.changeset/11514-dashboard-slot-entry-types.md b/.changeset/11514-dashboard-slot-entry-types.md index 304dabd95d..675e51b40c 100644 --- a/.changeset/11514-dashboard-slot-entry-types.md +++ b/.changeset/11514-dashboard-slot-entry-types.md @@ -10,4 +10,4 @@ - **Slot-entry reads.** `DashboardRenderer`, `DashboardGridLayout`, `DashboardWithConfig` and the retired-widget detector read a `widgets[]` entry by the slot's element type, `DashboardComponentSchema['widgets'][number]`, which is what lets `@object-ui/types` drop the component type from `DashboardWidgetSchema['type']`. - **The `object-chart` producers.** A series dispatch carries the family as a literal union whose members are all families `ObjectChartSchema.chartType` declares, so the node both surfaces build for a `provider: 'object'` series widget satisfies `ObjectChartSchema` with no cast. - **README.** "Reading a widget key off `widgets[]`" narrows an entry on `type` before reading a widget key, and a new section states what a widget with no `type`, or an unknown one, draws. -- **What does not move.** Every widget that names a known family draws as before: the dispatch routes the same families, and each `object-chart` node carries the same `chartType` it carried. +- **What does not move.** Every widget that names a known family draws as before: the dispatch routes the same families, and each `object-chart` node carries the same `chartType` it carried. A legacy `component` envelope with no `type` is not the spec's widget and is not given its default; it draws under its card heading as before, and a number or `true` in its `component` draws the same text, now forwarded through `toRenderableSchema`. diff --git a/content/docs/plugins/plugin-dashboard.mdx b/content/docs/plugins/plugin-dashboard.mdx index d63a97f33c..482ec8b476 100644 --- a/content/docs/plugins/plugin-dashboard.mdx +++ b/content/docs/plugins/plugin-dashboard.mdx @@ -400,6 +400,10 @@ inline or bound to a dataset (objectui#11514). objectui's validator accepts the widget without a `type` and does not write the default in, so the surfaces are where it resolves. +objectui's legacy `component` envelope (`{ id, component, layout }`) is not the +spec's widget, and the default is not applied to it: an envelope with no `type` +draws its `component` under its card heading, as it always did. + A `type` that names no widget family and no component type (a typo, or a family the spec no longer has) is refused by both validator faces at `type`. A stored one draws the labelled placeholder "「type」chart type is not supported yet", as diff --git a/packages/plugin-dashboard/README.md b/packages/plugin-dashboard/README.md index fdd34e082e..ec427ecb90 100644 --- a/packages/plugin-dashboard/README.md +++ b/packages/plugin-dashboard/README.md @@ -531,6 +531,10 @@ inline or bound to a dataset (objectui#11514). objectui's validator accepts the widget without a `type` and does not write the default in, so the surfaces are where it resolves. +objectui's legacy `component` envelope (`{ id, component, layout }`) is not the +spec's widget, and the default is not applied to it: an envelope with no `type` +draws its `component` under its card heading, as it always did. + A `type` that names no widget family and no component type (a typo, or a family the spec no longer has) is refused by both validator faces at `type`. A stored one draws the labelled placeholder "「type」chart type is not supported yet", as