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..675e51b40c --- /dev/null +++ b/.changeset/11514-dashboard-slot-entry-types.md @@ -0,0 +1,13 @@ +--- +'@object-ui/plugin-dashboard': minor +--- + +`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, 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. 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/.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. diff --git a/content/docs/plugins/plugin-dashboard.mdx b/content/docs/plugins/plugin-dashboard.mdx index b08c9f788f..88e191bf76 100644 --- a/content/docs/plugins/plugin-dashboard.mdx +++ b/content/docs/plugins/plugin-dashboard.mdx @@ -390,6 +390,26 @@ 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. + +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 +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 @@ -537,18 +557,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/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-dashboard/README.md b/packages/plugin-dashboard/README.md index fcb54dee31..ec427ecb90 100644 --- a/packages/plugin-dashboard/README.md +++ b/packages/plugin-dashboard/README.md @@ -521,6 +521,26 @@ 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. + +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 +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 @@ -670,18 +690,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 diff --git a/packages/plugin-dashboard/src/DashboardGridLayout.tsx b/packages/plugin-dashboard/src/DashboardGridLayout.tsx index a4882af2a2..e8e219eec9 100644 --- a/packages/plugin-dashboard/src/DashboardGridLayout.tsx +++ b/packages/plugin-dashboard/src/DashboardGridLayout.tsx @@ -3,13 +3,22 @@ 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, 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, + entryComponent, + isSlotComponentEntry, + resolveWidgetType, + toDashboardNodeType, + unsupportedWidgetSchema, + type DashboardWidgetSlotEntry, +} from './widgetDispatch'; import { LEGACY_RETIRED_WIDGET_SCHEMA, isLegacyRetiredWidget } from './legacyRetiredWidget'; import { DatasetWidget } from './DatasetWidget'; import { useWidgetSubCaption } from './widgetSubCaption'; @@ -117,7 +126,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 +202,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,11 +254,15 @@ 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). - if (widget.component) return toDashboardNodeType(widget.component); + const authoredComponent = entryComponent(widget); + // `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 @@ -264,7 +274,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 / @@ -316,6 +328,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 +347,7 @@ export const DashboardGridLayout: React.FC = ({ isAnimationActive: false, className: "h-full", ...chartPresentation, - }; + } satisfies ObjectChartSchema; } const dataItems = Array.isArray(widgetData) ? widgetData : widgetData?.items || []; @@ -461,21 +477,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 ( @@ -562,19 +578,19 @@ 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 - // 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 @@ -611,7 +627,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 eb9046b9d1..bc22f1333f 100644 --- a/packages/plugin-dashboard/src/DashboardRenderer.tsx +++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx @@ -6,8 +6,8 @@ * LICENSE file in the root directory of this source tree. */ -import type { BaseSchema, DashboardComponentSchema, DashboardWidgetSchema, DataSource, ObjectChartSchema, ObjectDataTableSchema } from '@object-ui/types'; -import { SchemaRenderer, useActionEngine, useObjectLabel, PageVariablesProvider, usePageVariables, useResolvedDataSource } from '@object-ui/react'; +import type { BaseSchema, DashboardComponentSchema, DataSource, ObjectChartSchema, ObjectDataTableSchema } from '@object-ui/types'; +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 { @@ -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, resolveWidgetType, isSlotComponentEntry, unsupportedWidgetSchema, entryComponent, 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 @@ -665,7 +669,10 @@ const DashboardRendererInner = forwardRef { // The author-supplied node keeps its spelling; only a `metric` / // `metric-card` node key moves onto its namespaced registration // (`toDashboardNodeType`, objectui#10859 batch 8). - if (widget.component) return toDashboardNodeType(widget.component); + const authoredComponent = entryComponent(widget); + // `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)); // Handle Shorthand Registry Mappings - const widgetType = widget.type; + const widgetType = entryType; const options = (widget.options || {}) as Record; // Renderer-internal data sources only (ADR-0021): the inline // `options.data` / `widget.data` array, or the `provider: 'object'` @@ -716,7 +729,6 @@ 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) => 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: DashboardWidgetSchema) => w.type !== 'metric').map((w: DashboardWidgetSchema) => 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: DashboardWidgetSchema) => w.type === 'metric') || []; - const otherWidgets = schema.widgets?.filter((w: DashboardWidgetSchema) => 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 = (
@@ -1317,7 +1339,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 +1348,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 +1393,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/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__/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/__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..0c1e88e882 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/dashboard-producer-node-types-11514.test.tsx @@ -0,0 +1,169 @@ +/** + * 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); + }); +}); + +/* ── 3. the legacy `component` envelope's primitives draw as before ──────── */ + +describe.each(SURFACES)('%s surface — a primitive in a widget\'s `component` envelope (objectui#11514)', (surface) => { + // 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/__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); + }); +}); 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..7f60e1c686 100644 --- a/packages/plugin-dashboard/src/widgetDispatch.ts +++ b/packages/plugin-dashboard/src/widgetDispatch.ts @@ -23,6 +23,110 @@ * widget was never routed. */ +import { DASHBOARD_COMPONENT_WIDGET_TYPES } from '@object-ui/types'; +import type { + DashboardComponentSchema, + DashboardWidgetSchema, + 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 + * 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]; + +/** + * 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. + * + * ⛔ 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): DashboardWidgetSlotEntry['type'] { + if (entry.type !== undefined) return entry.type; + return entryComponent(entry) ? undefined : specDefaultWidgetType(); +} + +/** + * The node an entry's legacy `component` envelope holds, typed as the widget + * arm declares that member (`DashboardWidgetSchema['component']`). Read off the + * entry whichever arm it is, as the surfaces always read it. The component arm + * declares no `component`, so a bare `entry.component` on the slot entry reads + * through `BaseSchema`'s index signature and is `any` — which made each + * surface's whole `getComponentSchema` return `any` (objectui#11514). + */ +export function entryComponent(entry: DashboardWidgetSlotEntry): DashboardWidgetSchema['component'] { + return entry.component; +} + +/** + * 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 | undefined) { + 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 @@ -41,7 +145,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 +154,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 +218,7 @@ export type WidgetDispatchFamily = export interface WidgetDispatch { family: WidgetDispatchFamily; /** For `series`: the alias-resolved base family to draw. */ - chartType?: string; + chartType?: SeriesChartFamily; } /** @@ -108,7 +229,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' }; 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'); }); }); 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 6adc9db636..e489056d13 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; /**