diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts index 72eb3decac..e46a768aa3 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; +import { PageComponentType } from '@objectstack/spec/ui'; import { BLOCK_CONFIG, blockHasConfig } from '../block-config'; -import { BLOCK_TYPE_META } from '../block-types'; +import { BLOCK_TYPE_META, PALETTE_EXCLUSIONS } from '../block-types'; describe('block-config', () => { it('exposes a configurable panel for every content block with authorable props', () => { @@ -8,7 +9,7 @@ describe('block-config', () => { 'element:text', 'element:image', 'element:number', 'element:button', 'page:header', 'page:card', 'page:tabs', 'page:accordion', 'record:related_list', 'record:highlights', 'record:details', 'record:alert', - 'record:path', 'record:quick_actions', 'ai:chat_window', 'ai:input', + 'record:path', 'record:quick_actions', 'ai:input', 'element:definition-list', 'element:repeater', ]) { expect(blockHasConfig(type), type).toBe(true); @@ -22,19 +23,6 @@ describe('block-config', () => { expect(blockHasConfig(undefined)).toBe(false); }); - it('prunes shell-singleton and unimplemented blocks from the page palette', () => { - for (const type of [ - 'app:launcher', 'global:notifications', 'user:profile', // shell singletons - 'element:form', 'element:filter', 'element:record_picker', // no renderer - ]) { - expect((BLOCK_TYPE_META as any)[type]).toBeUndefined(); - } - // page-content navigation stays; the real list primitives are now in the palette - expect((BLOCK_TYPE_META as any)['nav:menu']).toBeTruthy(); - expect((BLOCK_TYPE_META as any)['element:definition-list']).toBeTruthy(); - expect((BLOCK_TYPE_META as any)['element:repeater']).toBeTruthy(); - }); - it('also exposes the array-valued blocks', () => { for (const type of ['page:tabs', 'record:details', 'record:highlights']) { expect(blockHasConfig(type)).toBe(true); @@ -61,3 +49,68 @@ describe('block-config', () => { } }); }); + +/** + * Palette coverage ↔ spec `PageComponentType` (#2943). + * + * The previous version of this suite hand-asserted a handful of palette + * EXCLUSIONS (`expect(BLOCK_TYPE_META['element:form']).toBeUndefined()`), which + * locks drift in rather than detecting it: a new spec block type could land and + * simply never reach the palette, with nothing failing. These derive coverage + * from the enum instead, so every value must be an explicit decision — + * offered, or excluded with a documented reason. + */ +describe('page palette ↔ spec PageComponentType coverage', () => { + const specNames: string[] = (() => { + const raw = (PageComponentType as unknown as { options?: readonly string[] }).options; + return Array.isArray(raw) ? [...raw] : []; + })(); + + it('reads a non-empty enum from the spec', () => { + expect(specNames, 'could not read PageComponentType.options from the spec').not.toEqual([]); + }); + + it('every spec block type is either offered or explicitly excluded', () => { + const undecided = specNames.filter( + (t) => !(t in (BLOCK_TYPE_META as Record)) && !(t in PALETTE_EXCLUSIONS), + ); + // If this fails: a spec page-block type has no palette decision. Either add + // it to BLOCK_TYPE_META (offered — it needs a renderer!) or to + // PALETTE_EXCLUSIONS with the reason it is unauthorable. + expect(undecided).toEqual([]); + }); + + it('no type is both offered and excluded', () => { + const both = Object.keys(PALETTE_EXCLUSIONS).filter( + (t) => t in (BLOCK_TYPE_META as Record), + ); + expect(both, 'a block cannot be offered and excluded at once').toEqual([]); + }); + + it('every exclusion names a real spec type and carries a reason', () => { + for (const [type, reason] of Object.entries(PALETTE_EXCLUSIONS)) { + expect(specNames, `'${type}' is not a spec PageComponentType — stale exclusion`).toContain(type); + expect(reason.length, `'${type}' needs a reason`).toBeGreaterThan(10); + } + }); + + it('ai:chat_window is not offered — it has no inline renderer', () => { + // The palette used to offer it WITH a config panel while + // `components/renderers/placeholders.tsx` deliberately excluded it to force + // a loud error: an author dragged a block Studio advertised and got a red + // "Unknown component type" box. + expect((BLOCK_TYPE_META as any)['ai:chat_window']).toBeUndefined(); + expect(blockHasConfig('ai:chat_window')).toBe(false); + expect(PALETTE_EXCLUSIONS['ai:chat_window']).toBeTruthy(); + }); + + it('a block with a config panel is a block the palette offers', () => { + // A panel for an unauthorable block is how the ai:chat_window + // contradiction stayed invisible. Non-spec objectui blocks (`object-grid`, + // `grid`, …) are exempt — they are palette-native, not PageComponentType. + const orphanPanels = Object.keys(BLOCK_CONFIG).filter( + (t) => specNames.includes(t) && !(t in (BLOCK_TYPE_META as Record)), + ); + expect(orphanPanels, 'these expose a config panel but cannot be authored').toEqual([]); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/previews/block-config.ts b/packages/app-shell/src/views/metadata-admin/previews/block-config.ts index 2ea3ead6ca..3ce779664d 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/block-config.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/block-config.ts @@ -349,10 +349,9 @@ export const BLOCK_CONFIG: Record = { ], // ── AI ──────────────────────────────────────────────────────────────────── - 'ai:chat_window': [ - { name: 'agentName', label: 'Agent', kind: 'text', placeholder: 'agent name' }, - { name: 'placeholder', label: 'Input placeholder', kind: 'text' }, - ], + // No `ai:chat_window` panel: the block is not in the palette (no inline + // renderer — see block-types.ts PALETTE_EXCLUSIONS, #2943). A config panel + // for an unauthorable block is how the contradiction stayed invisible. 'ai:input': [ { name: 'agentName', label: 'Agent', kind: 'text', placeholder: 'agent name' }, { name: 'placeholder', label: 'Input placeholder', kind: 'text' }, diff --git a/packages/app-shell/src/views/metadata-admin/previews/block-types.ts b/packages/app-shell/src/views/metadata-admin/previews/block-types.ts index a541432ca1..56ce7fc355 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/block-types.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/block-types.ts @@ -64,7 +64,7 @@ export type BlockTypeId = // global:* | 'global:search' // ai:* - | 'ai:chat_window' | 'ai:suggestion' + | 'ai:suggestion' // element:* | 'element:text' | 'element:number' | 'element:image' | 'element:divider' | 'element:button' | 'element:definition-list' | 'element:repeater'; @@ -113,7 +113,12 @@ export const BLOCK_TYPE_META: Record> = { 'global:search': { label: 'Global search', category: 'navigation', Icon: Search }, // AI - 'ai:chat_window': { label: 'AI chat window', category: 'ai', Icon: Bot }, + // `ai:chat_window` is deliberately NOT in the palette: the floating chat + // overlay (plugin-chatbot) is the canonical entry point and there is no + // inline page-level renderer — `components/renderers/placeholders.tsx` + // documents that exclusion. The palette used to offer it (with a config + // panel), so an author dragged a block Studio advertised and got a red + // "Unknown component type" box (#2943). See PALETTE_EXCLUSIONS. 'ai:suggestion': { label: 'AI suggestion', category: 'ai', Icon: Sparkles }, // Elements @@ -126,6 +131,34 @@ export const BLOCK_TYPE_META: Record> = { 'element:repeater': { label: 'Repeater', category: 'element', Icon: Rows3 }, }; +/** + * Every spec `PageComponentType` the page palette deliberately does NOT offer, + * each with the reason it is unauthorable. This is the inverse of the old + * guard (#2943): `block-config.test.ts` used to hand-assert a few exclusions, + * which locks drift IN — a new spec block type could land and simply never + * appear in the palette, unnoticed. The test now derives coverage from + * `PageComponentType` and requires every value to be either in + * {@link BLOCK_TYPE_META} or listed here, so a new one fails until someone + * decides about it. + * + * A palette entry with no renderer is the failure this closes: `ai:chat_window` + * was offered WITH a config panel while `placeholders.tsx` deliberately + * excluded it to force a loud error — an author dragged a block Studio + * advertised and got a red box. + */ +export const PALETTE_EXCLUSIONS: Record = { + // Shell singletons — chrome the app shell owns, not page content. + 'app:launcher': 'shell singleton — the app shell renders it, not a page', + 'global:notifications': 'shell singleton — lives in the app shell header', + 'user:profile': 'shell singleton — lives in the app shell header', + // No renderer, by decision. + 'ai:chat_window': 'no inline renderer — the floating chat overlay (plugin-chatbot) is canonical', + 'element:filter': 'no renderer — list surfaces own filtering (userFilters / filter builder)', + 'element:form': 'no renderer — use the object-bound `object-form` block', + 'element:record_picker': 'no renderer — record picking is a field widget, not a page block', + 'element:text_input': 'no renderer — bare inputs belong to a form, not a page block', +}; + export const CATEGORY_LABEL_EN: Record = { data: 'Data', layout: 'Layout', diff --git a/packages/components/src/__tests__/palette-placeholder-blocks.test.tsx b/packages/components/src/__tests__/palette-placeholder-blocks.test.tsx new file mode 100644 index 0000000000..250586007a --- /dev/null +++ b/packages/components/src/__tests__/palette-placeholder-blocks.test.tsx @@ -0,0 +1,63 @@ +/** + * 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. + */ + +/** + * Palette-offered page blocks render in EVERY host (#2943). + * + * Placeholder registration was entirely opt-in (`registerPlaceholders()`), and + * only `apps/console` calls it. So `nav:menu`, `nav:breadcrumb`, + * `global:search` and `ai:suggestion` — blocks the Studio palette offers — + * rendered `SchemaRenderer`'s red `role="alert"` panel in every other host, + * dumping the page JSON, purely because that host skipped an optional + * bootstrap. + * + * These four now register eagerly through the `./renderers` barrel. The rest of + * the protocol vocabulary stays opt-in on purpose: a genuinely missing renderer + * must keep failing loudly so the misconfiguration gets fixed at the source. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import '@testing-library/jest-dom'; +import { ComponentRegistry } from '@object-ui/core'; +import { PALETTE_PLACEHOLDER_BLOCKS } from '../renderers/placeholders'; + +beforeAll(async () => { + // Import the barrel ONLY — never `registerPlaceholders()`, which is what + // this test exists to prove is not required for these blocks. + await import('../renderers'); +}, 30000); + +describe('palette-offered blocks render without the optional bootstrap', () => { + it('declares a non-empty eager set', () => { + expect(PALETTE_PLACEHOLDER_BLOCKS.length).toBeGreaterThan(0); + }); + + it.each(PALETTE_PLACEHOLDER_BLOCKS)('%s resolves to a renderer', (type) => { + expect( + ComponentRegistry.get(type), + `${type} is offered by the Studio palette — it must not red-box in a host that skips registerPlaceholders()`, + ).toBeTruthy(); + }); + + it('keeps the rest of the protocol vocabulary opt-in (loud by design)', () => { + // A sample of PROTOCOL_COMPONENTS members outside the eager set: these are + // NOT palette blocks, and a page referencing one is a misconfiguration that + // should stay loud. + for (const type of ['view:kanban', 'widget:heatmap', 'field:duration']) { + expect( + ComponentRegistry.get(type), + `${type} must stay opt-in — auto-registering it would mask a missing renderer`, + ).toBeFalsy(); + } + }); + + it('ai:chat_window stays unregistered — the exclusion is deliberate', () => { + // The floating chat overlay (plugin-chatbot) is canonical; #2943 pruned the + // palette entry rather than adding a placeholder for it. + expect(ComponentRegistry.get('ai:chat_window')).toBeFalsy(); + }); +}); diff --git a/packages/components/src/renderers/index.ts b/packages/components/src/renderers/index.ts index ae5157e598..ba49824d1c 100644 --- a/packages/components/src/renderers/index.ts +++ b/packages/components/src/renderers/index.ts @@ -16,3 +16,7 @@ import './overlay'; import './disclosure'; import './complex'; import './action'; +// Registers placeholders for the page blocks the Studio palette offers but no +// package renders yet, so they don't red-box in hosts that skip the optional +// `registerPlaceholders()` bootstrap (#2943). The rest stay opt-in. +import './placeholders'; diff --git a/packages/components/src/renderers/placeholders.tsx b/packages/components/src/renderers/placeholders.tsx index bfd2b0cfb9..e93c072960 100644 --- a/packages/components/src/renderers/placeholders.tsx +++ b/packages/components/src/renderers/placeholders.tsx @@ -102,11 +102,40 @@ const PROTOCOL_COMPONENTS = [ 'ai:input', 'ai:suggestion', 'ai:feedback' ]; +/** + * Page blocks the Studio palette OFFERS but no package renders yet. + * + * These are registered eagerly (see the side-effect call below, reached through + * the `./renderers` barrel) rather than waiting for a host to opt in via + * {@link registerPlaceholders} — which only `apps/console` does. A block Studio + * advertises must not render a red "Unknown component type" panel in every + * other host just because that host didn't call an optional bootstrap (#2943). + * + * Deliberately NARROW: the rest of {@link PROTOCOL_COMPONENTS} stays opt-in so + * a genuinely missing renderer keeps failing loudly, which is what makes the + * misconfiguration get fixed at the source (the same reasoning that keeps + * `ai:chat_window` out of this file entirely — and, since #2943, out of the + * palette too). + */ +export const PALETTE_PLACEHOLDER_BLOCKS = [ + 'nav:menu', 'nav:breadcrumb', 'global:search', 'ai:suggestion', +]; + +/** Register one placeholder, never overwriting a real implementation. */ +function registerPlaceholder(type: string) { + if (!ComponentRegistry.get(type)) { + ComponentRegistry.register(type, PlaceholderRenderer, { namespace: 'protocol-placeholder' }); + } +} + +/** + * Register placeholders for the whole protocol vocabulary. Opt-in: hosts that + * want every declared component to render *something* (the console's preview + * gallery) call this; others keep the loud unknown-type error. + */ export function registerPlaceholders() { - PROTOCOL_COMPONENTS.forEach(type => { - // Only register if not already registered (to avoid overwriting real implementations) - if (!ComponentRegistry.get(type)) { - ComponentRegistry.register(type, PlaceholderRenderer, { namespace: 'protocol-placeholder' }); - } - }); + PROTOCOL_COMPONENTS.forEach(registerPlaceholder); } + +// Palette-offered page blocks render everywhere, in every host (see above). +PALETTE_PLACEHOLDER_BLOCKS.forEach(registerPlaceholder); diff --git a/packages/plugin-dashboard/src/DashboardGridLayout.tsx b/packages/plugin-dashboard/src/DashboardGridLayout.tsx index 39e15ba994..b7110bff2a 100644 --- a/packages/plugin-dashboard/src/DashboardGridLayout.tsx +++ b/packages/plugin-dashboard/src/DashboardGridLayout.tsx @@ -6,6 +6,7 @@ import { Edit, GripVertical, Save, X, RefreshCw } from 'lucide-react'; import { SchemaRenderer, useHasDndProvider, useDnd } from '@object-ui/react'; import type { DashboardSchema, DashboardWidgetSchema } from '@object-ui/types'; import { isObjectProvider } from './utils'; +import { classifyWidgetType } from './widgetDispatch'; /** Bridges editMode transitions to the ObjectUI DnD system when a DndProvider is present. */ function DndEditModeBridge({ editMode }: { editMode: boolean }) { @@ -170,7 +171,12 @@ export const DashboardGridLayout: React.FC = ({ const widgetType = widget.type; const options = (widget.options || {}) as Record; - if (widgetType === 'bar' || widgetType === 'horizontal-bar' || widgetType === 'line' || widgetType === 'area' || widgetType === 'pie' || widgetType === 'donut' || widgetType === 'scatter' || widgetType === 'funnel') { + // One shared classification (./widgetDispatch) — this surface used to name + // 8 chart families by hand while DatasetWidget covered all 19, so radar / + // treemap / sankey and the single-value families fell through to a red + // "Unknown component type" box (#2943). + const dispatch = classifyWidgetType(widgetType); + if (dispatch.family === 'series' && dispatch.chartType) { const widgetData = (widget as any).data || options.data; const xAxisKey = options.xField || 'name'; const yField = options.yField || 'value'; @@ -188,7 +194,7 @@ export const DashboardGridLayout: React.FC = ({ const effectiveYField = effectiveAggregate?.field || yField; return { type: 'object-chart', - chartType: widgetType, + chartType: dispatch.chartType, objectName: widgetData.object, aggregate: effectiveAggregate, xAxisKey: xAxisKey, @@ -204,7 +210,7 @@ export const DashboardGridLayout: React.FC = ({ return { type: 'chart', - chartType: widgetType, + chartType: dispatch.chartType, data: dataItems, xAxisKey: xAxisKey, series: [{ dataKey: yField }], @@ -215,7 +221,36 @@ export const DashboardGridLayout: React.FC = ({ }; } - if (widgetType === 'table') { + // Single-value families render as a metric card, not a chart (#2943). + if (dispatch.family === 'metric') { + const widgetData = (widget as any).data || options.data; + const label = widget.title || widgetType; + if (isObjectProvider(widgetData)) { + const providerAgg = widgetData.aggregate; + return { + type: 'object-metric', + ...options, + objectName: widgetData.object, + label, + aggregate: providerAgg ? { + field: providerAgg.field, + function: providerAgg.function, + groupBy: providerAgg.groupBy, + } : undefined, + filter: widgetData.filter || widget.filter, + }; + } + const rows = Array.isArray(widgetData) ? widgetData : widgetData?.items || []; + const valueField = options.yField || 'value'; + return { + type: 'metric', + ...options, + label, + value: options.value ?? rows[0]?.[valueField] ?? '—', + }; + } + + if (dispatch.family === 'table') { const widgetData = (widget as any).data || options.data; // provider: 'object' — pass through object config for async data loading @@ -243,7 +278,7 @@ export const DashboardGridLayout: React.FC = ({ }; } - if (widgetType === 'pivot') { + if (dispatch.family === 'pivot') { const widgetData = (widget as any).data || options.data; // provider: 'object' — pass through object config for async data loading @@ -265,6 +300,16 @@ export const DashboardGridLayout: React.FC = ({ }; } + if (dispatch.family === 'unsupported') { + return { + type: 'text', + value: `「${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 { ...widget, ...options diff --git a/packages/plugin-dashboard/src/DashboardRenderer.tsx b/packages/plugin-dashboard/src/DashboardRenderer.tsx index 2db1200829..3b4ee2e3b8 100644 --- a/packages/plugin-dashboard/src/DashboardRenderer.tsx +++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx @@ -36,6 +36,7 @@ import { } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { isObjectProvider } from './utils'; +import { classifyWidgetType, METRIC_LIKE_TYPES } from './widgetDispatch'; import { DatasetWidget } from './DatasetWidget'; import { DashboardFilterBar } from './DashboardFilterBar'; @@ -104,55 +105,10 @@ const CHART_COLORS = [ ]; /** - * Spec-level `ChartType` families normalized to a base type that the chart - * implementation (plugin-charts/AdvancedChartImpl) actually renders. The - * widget `type` taxonomy is broader than the set of distinct visual renderers; - * several families collapse onto a shared renderer (e.g. stacked / grouped / - * column bars all render through the bar chart). Keeps the dashboard from - * surfacing a raw "Unknown component type" for a perfectly meaningful chart. + * Chart-family vocabularies live in `./widgetDispatch`, shared with + * `DashboardGridLayout` and asserted against `ChartTypeSchema` — the three + * dashboard surfaces used to restate them independently and disagree (#2943). */ -const CHART_TYPE_ALIASES: Record = { - column: 'bar', - 'stacked-bar': 'bar', - 'grouped-bar': 'bar', - 'bi-polar-bar': 'bar', - spline: 'line', - 'step-line': 'line', - 'stacked-area': 'area', - pyramid: 'funnel', - bubble: 'scatter', -}; - -/** - * Chart types the renderer can draw (after alias normalization). Anything - * outside this set that is still a chart family renders a clean - * "not-yet-supported" placeholder instead of a raw error box. - */ -const SUPPORTED_CHART_TYPES = new Set([ - 'bar', 'horizontal-bar', 'line', 'area', 'pie', 'donut', 'scatter', 'funnel', 'radar', - 'treemap', 'sankey', -]); - -/** - * Single-value "performance" widgets that render as a metric card rather than - * a chart (gauge/kpi/bullet are all one number with optional target). - */ -const METRIC_LIKE_TYPES = new Set(['gauge', 'solid-gauge', 'kpi', 'bullet']); - -/** - * Chart families that have no dedicated renderer yet (Recharts cannot draw - * them and no approximation reads honestly). These render a labelled - * placeholder so the dashboard stays clean instead of dumping the widget JSON - * under a red "Unknown component type" error. - */ -const UNSUPPORTED_CHART_TYPES = new Set([ - // Safety net: chart families dropped from the ChartType protocol (they need - // richer data — OHLC / per-record distributions — or a geo dependency). - // Kept here so any stale dashboard still referencing them renders a clean - // placeholder rather than a raw "Unknown component type" error. - 'sunburst', 'word-cloud', 'choropleth', 'bubble-map', 'gl-map', - 'heatmap', 'waterfall', 'box-plot', 'violin', 'candlestick', 'stock', -]); /** * Chart sub-types that have a meaningful drill-down interaction. @@ -498,12 +454,15 @@ const DashboardRendererInner = forwardRef { + const raw = (ChartTypeSchema as unknown as { options?: readonly string[] }).options; + return Array.isArray(raw) ? [...raw] : []; +})(); + +describe('widget dispatch covers the spec chart vocabulary', () => { + it('reads a non-empty enum from the spec', () => { + expect(specNames, 'could not read ChartTypeSchema.options from the spec').not.toEqual([]); + }); + + it('no spec chart type falls through to the red error box', () => { + const unrouted = specNames.filter((name) => classifyWidgetType(name).family === 'passthrough'); + expect( + unrouted, + 'these render SchemaRenderer\'s red "Unknown component type" panel — route them in widgetDispatch', + ).toEqual([]); + }); + + it('the single-value families route to the metric card, not a chart', () => { + for (const name of ['gauge', 'solid-gauge', 'kpi', 'bullet', 'metric']) { + expect(classifyWidgetType(name).family, `'${name}' must render as a metric`).toBe('metric'); + } + }); + + it('the tabular families route to a table', () => { + expect(classifyWidgetType('table').family).toBe('table'); + expect(classifyWidgetType('list').family).toBe('table'); + // `pivot` is dataset-bound only — a non-dataset pivot is stale metadata. + expect(classifyWidgetType('pivot').family).toBe('pivot'); + }); + + it('every alias resolves onto a family the chart renderer draws', () => { + for (const [alias, base] of Object.entries(CHART_TYPE_ALIASES)) { + const dispatch = classifyWidgetType(alias); + expect(dispatch.family, `alias '${alias}'`).toBe('series'); + expect(dispatch.chartType, `alias '${alias}' → '${base}'`).toBe(base); + expect(SERIES_CHART_TYPES.has(base), `'${base}' must be drawable`).toBe(true); + } + }); + + it('series and metric vocabularies do not overlap', () => { + const overlap = [...SERIES_CHART_TYPES].filter((n) => METRIC_LIKE_TYPES.has(n) || TABLE_LIKE_TYPES.has(n)); + expect(overlap, 'a type cannot be both a series chart and a metric/table').toEqual([]); + }); + + it('a genuinely unknown type stays passthrough (the surface decides)', () => { + expect(classifyWidgetType('totally-made-up').family).toBe('passthrough'); + expect(classifyWidgetType(undefined).family).toBe('passthrough'); + }); + + it('the dropped-family safety net returns a labelled placeholder, not passthrough', () => { + for (const name of ['heatmap', 'candlestick', 'choropleth']) { + expect(classifyWidgetType(name).family, `'${name}'`).toBe('unsupported'); + } + }); +}); diff --git a/packages/plugin-dashboard/src/widgetDispatch.ts b/packages/plugin-dashboard/src/widgetDispatch.ts new file mode 100644 index 0000000000..bdeace6fd1 --- /dev/null +++ b/packages/plugin-dashboard/src/widgetDispatch.ts @@ -0,0 +1,112 @@ +/** + * 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. + */ + +/** + * Widget-type → dispatch family, shared by every dashboard surface. + * + * Three surfaces used to restate this knowledge independently and disagree: + * `DatasetWidget` covered all 19 `ChartTypeSchema` values, `DashboardRenderer` + * covered 15, and `DashboardGridLayout` 8. A type the surface didn't name fell + * through to `{ ...widget }`, whose `type` no component registers — so + * `SchemaRenderer` rendered a red `role="alert"` panel dumping the widget JSON + * (#2943). + * + * The single-value families were the sharpest case: `DashboardRenderer` already + * had `METRIC_LIKE_TYPES = ['gauge','solid-gauge','kpi','bullet']` with a + * docstring saying they "render as a metric card rather than a chart" — and + * consulted it only to pick a grid span. The tile was sized correctly and the + * widget was never routed. + */ + +/** + * 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 + * `ChartTypeSchema` comment records the same equivalences). + */ +export const CHART_TYPE_ALIASES: Record = { + column: 'bar', + 'stacked-bar': 'bar', + 'grouped-bar': 'bar', + 'bi-polar-bar': 'bar', + spline: 'line', + 'step-line': 'line', + 'stacked-area': 'area', + pyramid: 'funnel', + bubble: 'scatter', +}; + +/** Cartesian / categorical / flow families the chart renderer draws. */ +export const SERIES_CHART_TYPES: ReadonlySet = new Set([ + 'bar', 'horizontal-bar', 'line', 'area', 'pie', 'donut', + 'scatter', 'funnel', 'radar', 'treemap', 'sankey', +]); + +/** + * Single-value "performance" families — one number, optionally against a + * target. They render as a metric card, which is what the spec's own + * `ChartTypeSchema` comment calls "honest single-value variants pending a real + * dial/target renderer". `metric` is the objectui widget spelling of the same + * thing. + */ +export const METRIC_LIKE_TYPES: ReadonlySet = new Set([ + 'metric', 'gauge', 'solid-gauge', 'kpi', 'bullet', +]); + +/** Tabular families — a grouped/flat table of records. */ +export const TABLE_LIKE_TYPES: ReadonlySet = new Set(['table', 'list']); + +/** + * Chart families with no renderer and no honest approximation (they need + * richer data — OHLC, per-record distributions — or a geo dependency). Some + * were dropped from `ChartTypeSchema` entirely; they stay named so stale + * stored dashboards get a labelled placeholder rather than a red error box. + */ +export const UNSUPPORTED_CHART_TYPES: ReadonlySet = new Set([ + 'sunburst', 'word-cloud', 'choropleth', 'bubble-map', 'gl-map', + 'heatmap', 'waterfall', 'box-plot', 'violin', 'candlestick', 'stock', +]); + +/** How a dashboard surface should render one widget type. */ +export type WidgetDispatchFamily = + /** A series chart (the resolved base family is in `chartType`). */ + | 'series' + /** A single-value metric card. */ + | 'metric' + /** A records table. */ + | 'table' + /** Cross-tab — dataset-bound only; a non-dataset pivot is stale metadata. */ + | 'pivot' + /** Author-supplied component schema required. */ + | 'custom' + /** Known family, no renderer — labelled placeholder. */ + | 'unsupported' + /** Not a widget vocabulary member — the surface passes it through. */ + | 'passthrough'; + +export interface WidgetDispatch { + family: WidgetDispatchFamily; + /** For `series`: the alias-resolved base family to draw. */ + chartType?: string; +} + +/** + * Classify a widget `type` into its dispatch family. Exported for the + * spec-parity test, which asserts that no `ChartTypeSchema` value lands on + * `passthrough` (the branch that produces the red box). + */ +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 (METRIC_LIKE_TYPES.has(widgetType)) return { family: 'metric' }; + if (TABLE_LIKE_TYPES.has(widgetType)) return { family: 'table' }; + if (widgetType === 'pivot') return { family: 'pivot' }; + if (widgetType === 'custom') return { family: 'custom' }; + if (UNSUPPORTED_CHART_TYPES.has(widgetType)) return { family: 'unsupported' }; + return { family: 'passthrough' }; +}