Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/26745-studio-widget-type-registry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/i18n': patch
---

fix(app-shell): the dashboard's Add-widget menu and the widget Type select offer every widget type the spec accepts, from one registry

Clause-②: no
Metadata-protocol: none

Studio's two dashboard widget-type pickers kept their own lists and disagreed with each other and with the spec: the Add-widget menu offered 11 types, the inspector's Type select 10 (no `scatter`), and `ChartTypeSchema` accepts 20, all of which a dashboard draws. Both pickers now read one registry derived from `ChartTypeSchema`, so they offer the same 20 types and follow the spec when it changes.

- The `studio` namespace gains the names of the newly offered types: `engine.inspector.widget.type.*` for `column`, `scatter`, `treemap`, `sankey`, `combo`, `gauge`, `solidGauge`, `kpi`, `bullet` and `radar`, and `engine.widgetPicker.type.*` for the same types except `scatter`, in en and zh.
- `engine.inspector.widget.datasetHint` no longer mentions an "inline object query above", which the widget inspector has not had since ADR-0021; it describes the dataset, dimension and measure controls the inspector shows.
Original file line number Diff line number Diff line change
Expand Up @@ -50,35 +50,27 @@ import {
import { useDatasetCatalog, useDatasetSemantics } from '../previews/useDatasetCatalog.js';
import type { ObjectFieldInfo } from '../previews/useObjectFields.js';
import { widgetMissingInputs } from '../../studio-design/metadataError.js';
import { WIDGET_TYPE_IDS, WIDGET_TYPE_META } from '../previews/widget-types.js';

// ADR-0021: dashboard widgets author the semantic-layer dataset shape only
// (dataset + dimensions + values). The pre-ADR-0021 inline single-object query
// (object / valueField / categoryField / aggregate) was removed from the spec
// at @objectstack/spec 9.0.0 and is no longer authored here — its fields are
// gone so no Studio surface can emit the dead shape (framework#3251).
/**
* The widget families this inspector offers. TYPED to `DashboardWidgetTypeName`
* (objectui#4600, which closed the widget `type` vocabulary) so the inspector
* cannot offer a type validation refuses — every entry here is a spec
* visualization family. A hand-written SUBSET is fine; offering something
* outside the vocabulary is not.
* The widget families this inspector's Type select offers: the ONE registry
* the Add-widget menu reads too (`../previews/widget-types`, objectstack#26745),
* which derives the set from the spec's `ChartTypeSchema` instead of keeping a
* list here. The two pickers used to keep their own lists and disagreed (10
* here, 11 in the menu, 20 in the spec). ⛔ No hand-kept list in this file.
*
* Each label is a catalogue key, resolved in the designer locale at render
* (objectui#10748, the objectui#10586 `KeyedOption` shape); the stored `value`
* never moves.
*/
const WIDGET_TYPES: ReadonlyArray<{ value: DashboardWidgetTypeName; labelKey: string }> = [
{ value: 'metric', labelKey: 'engine.inspector.widget.type.metric' },
{ value: 'bar', labelKey: 'engine.inspector.widget.type.bar' },
{ value: 'horizontal-bar', labelKey: 'engine.inspector.widget.type.horizontalBar' },
{ value: 'line', labelKey: 'engine.inspector.widget.type.line' },
{ value: 'area', labelKey: 'engine.inspector.widget.type.area' },
{ value: 'pie', labelKey: 'engine.inspector.widget.type.pie' },
{ value: 'donut', labelKey: 'engine.inspector.widget.type.donut' },
{ value: 'funnel', labelKey: 'engine.inspector.widget.type.funnel' },
{ value: 'table', labelKey: 'engine.inspector.widget.type.table' },
{ value: 'pivot', labelKey: 'engine.inspector.widget.type.pivot' },
];
const WIDGET_TYPES: ReadonlyArray<{ value: DashboardWidgetTypeName; labelKey: string }> = WIDGET_TYPE_IDS.map(
(id) => ({ value: id, labelKey: WIDGET_TYPE_META[id].selectLabelKey }),
);

const COLORS = [
'default',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import { ObjectFieldInspector } from './ObjectFieldInspector';
import { FlowEdgeInspector } from './FlowEdgeInspector';
import { FlowNodeInspector } from './FlowNodeInspector';
import { encodeNestedNodeId, NESTED_NODE_KIND } from './flow-nested-selection';
import { WIDGET_TYPE_META } from '../previews/widget-types';
// The designer's `designer.*` / `perm.*` rows live in `@object-ui/i18n`'s `studio`
// namespace, whose `zh` catalogue is fetched on demand (objectstack#25922).
// Resident before any case, so the zh rows below read synchronously.
Expand Down Expand Up @@ -139,19 +140,14 @@ function mountWidget(lang: Lang, draft: Record<string, unknown>, selection: { ki
));
}

/** Stored widget `type` → the row its option label reads. */
const WIDGET_TYPE_KEYS: ReadonlyArray<[string, string]> = [
['metric', 'engine.inspector.widget.type.metric'],
['bar', 'engine.inspector.widget.type.bar'],
['horizontal-bar', 'engine.inspector.widget.type.horizontalBar'],
['line', 'engine.inspector.widget.type.line'],
['area', 'engine.inspector.widget.type.area'],
['pie', 'engine.inspector.widget.type.pie'],
['donut', 'engine.inspector.widget.type.donut'],
['funnel', 'engine.inspector.widget.type.funnel'],
['table', 'engine.inspector.widget.type.table'],
['pivot', 'engine.inspector.widget.type.pivot'],
];
/**
* Stored widget `type` → the row its option label reads — read off the one
* widget registry the select derives its options from (objectstack#26745).
*/
const WIDGET_TYPE_KEYS: ReadonlyArray<[string, string]> = Object.values(WIDGET_TYPE_META).map((m) => [
m.id,
m.selectLabelKey,
]);

/** The widget-type select's trigger text, for a widget stored with `type`. */
function widgetTypeTrigger(lang: Lang, type: string): string | null {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -383,20 +383,12 @@ describe('the Problems panel — expression messages (objectui#10804)', () => {

// ─── Site 4: the dashboard add-widget picker (objectui#10805) ────────────────

/** Stored `type` → the catalogue row its picker entry displays. */
const WIDGET_TYPE_KEYS: ReadonlyArray<[string, string]> = [
['metric', 'engine.widgetPicker.type.metric'],
['bar', 'engine.widgetPicker.type.bar'],
['horizontal-bar', 'engine.widgetPicker.type.horizontalBar'],
['line', 'engine.widgetPicker.type.line'],
['area', 'engine.widgetPicker.type.area'],
['pie', 'engine.widgetPicker.type.pie'],
['donut', 'engine.widgetPicker.type.donut'],
['scatter', 'engine.widgetPicker.type.scatter'],
['funnel', 'engine.widgetPicker.type.funnel'],
['table', 'engine.widgetPicker.type.table'],
['pivot', 'engine.widgetPicker.type.pivot'],
];
/**
* Stored `type` → the catalogue row its picker entry displays — read off the
* one widget registry (objectstack#26745), which derives the offered set from
* the spec, so this suite covers every type the picker offers.
*/
const WIDGET_TYPE_KEYS: ReadonlyArray<[string, string]> = Object.values(WIDGET_TYPE_META).map((m) => [m.id, m.labelKey]);
const CATEGORIES = ['kpi', 'chart', 'data'] as const;

/** Mount the dashboard inspector with no widgets and open its add-widget picker. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#26745 — the dashboard's two widget-type pickers read ONE registry
* of the renderable spec types.
*
* Measured on `main` before this change: the designer's Add-widget menu offered
* 11 types, the widget inspector's Type select 10 (no `scatter`), and the spec's
* `ChartTypeSchema` accepts 20 — every one of which the dashboard draws when
* written through the API. Each picker kept its own hand-written list.
*
* Pinned here, each through the rendered control:
* - the registry IS the spec vocabulary (`WIDGET_TYPE_IDS` = `ChartTypeSchema`);
* - the Add menu offers exactly that set, and so does the Type select;
* - every offered type renders through the real `DashboardRenderer` — no
* "not supported yet" placeholder, no red unknown-component panel;
* - every offered type has its two names in both catalogues (en + zh).
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { ChartTypeSchema } from '@objectstack/spec/ui';
import { enumOptions } from '@object-ui/test-support';
// Module-scope import of the lazily loaded renderer, so the preview's
// `React.lazy` factory resolves at once instead of racing the test's wait
// window (AGENTS.md, flaky-test discipline).
import '@object-ui/plugin-dashboard';
// The console registers the chart renderer the series families draw through.
import '@object-ui/plugin-charts';

vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
return { ...mod, useAdapter: () => ({}) };
});
// Network-free dataset catalog for the widget inspector.
vi.mock('./useDatasetCatalog', () => ({
useDatasetCatalog: () => ({ datasets: [], loading: false, error: null }),
useDatasetSemantics: () => ({ dimensions: [], measures: [], loading: false, error: null }),
}));

import { t } from '../i18n';
import { WIDGET_TYPE_IDS, WIDGET_TYPE_META } from './widget-types';
import { DashboardPreview } from './DashboardPreview';
import { DashboardDefaultInspector } from '../inspectors/DashboardDefaultInspector';
import { DashboardWidgetInspector } from '../inspectors/DashboardWidgetInspector';
// The `engine.inspector.*` rows live in `@object-ui/i18n`'s `studio` namespace,
// whose `zh` catalogue is fetched on demand (objectstack#25922). Resident before
// any case, so the zh rows below read synchronously.
import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
await loadStudioCatalogue('zh');

// Radix Select reads pointer capture, which jsdom does not implement.
for (const m of ['hasPointerCapture', 'setPointerCapture', 'releasePointerCapture'] as const) {
if (!(m in Element.prototype)) {
(Element.prototype as unknown as Record<string, unknown>)[m] = m === 'hasPointerCapture' ? () => false : () => {};
}
}
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {};

afterEach(cleanup);

const specTypes: string[] = enumOptions(ChartTypeSchema);

async function flush() {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
}

/** The stored types the Add-widget menu lists, read off its rendered entries. */
async function addMenuTypes(): Promise<string[]> {
render(
<DashboardDefaultInspector
type="dashboard"
name="sales"
locale="en-US"
draft={{ name: 'sales', label: 'Sales', widgets: [] }}
onPatch={() => {}}
onSelectionChange={() => {}}
readOnly={false}
/>,
);
fireEvent.click(screen.getByRole('button', { name: t('engine.inspector.add.widget', 'en-US') }));
await flush();
const picker = screen.getByRole('dialog');
return Array.from(picker.querySelectorAll('button code')).map((c) => c.textContent ?? '');
}

/** The option names the widget inspector's Type select lists. */
async function typeSelectOptions(): Promise<string[]> {
render(
<DashboardWidgetInspector
type="dashboard"
name="sales"
locale="en-US"
draft={{ widgets: [{ id: 'w1', type: 'bar', title: 'Revenue' }] }}
selection={{ kind: 'widget', id: 'w1' }}
onPatch={() => {}}
onClearSelection={() => {}}
onSelectionChange={() => {}}
readOnly={false}
/>,
);
fireEvent.keyDown(document.getElementById('widget-type')!, { key: 'ArrowDown' });
const listbox = await screen.findByRole('listbox');
return within(listbox)
.getAllByRole('option')
.map((o) => o.textContent ?? '');
}

describe('one widget-type registry, derived from the spec (objectstack#26745)', () => {
it('reads a non-empty enum from the spec', () => {
expect(specTypes, 'could not read ChartTypeSchema.options from the spec').not.toEqual([]);
});

it('the registry is the spec vocabulary, in the spec order', () => {
expect([...WIDGET_TYPE_IDS]).toEqual(specTypes);
expect(Object.keys(WIDGET_TYPE_META).sort()).toEqual([...specTypes].sort());
});

it('the Add-widget menu offers exactly the spec types', async () => {
expect((await addMenuTypes()).sort()).toEqual([...specTypes].sort());
});

it('the Type select offers exactly the spec types, named from the same registry', async () => {
expect(await typeSelectOptions()).toEqual(
specTypes.map((id) => t(WIDGET_TYPE_META[id].selectLabelKey, 'en-US')),
);
});

/** Render one widget per type through the real renderer; the drawn text once every cell is laid out. */
async function renderedText(types: readonly string[]): Promise<string> {
const widgets = types.map((type, i) => ({
id: `w_${i}`,
type,
title: `Widget ${type}`,
layout: { x: (i % 4) * 3, y: Math.floor(i / 4) * 2, w: 3, h: 2 },
}));
const { container } = render(
<DashboardPreview
type="dashboard"
name="all_types"
draft={{ name: 'all_types', label: 'All types', widgets }}
editing={false}
selection={null}
onSelectionChange={() => {}}
onPatch={() => {}}
locale="en-US"
/>,
);
await waitFor(() => {
const cells = container.querySelectorAll('[style*="grid-column"]');
if (cells.length !== widgets.length) throw new Error(`expected ${widgets.length} widget cells, saw ${cells.length}`);
});
return container.textContent ?? '';
}

it('every offered type renders through the real DashboardRenderer — no placeholder, no red panel', async () => {
const text = await renderedText(WIDGET_TYPE_IDS);
expect(text).not.toMatch(/chart type is not supported yet/);
expect(text).not.toMatch(/Unknown component type/);
});

it('control: the same probe sees the placeholder a family with no renderer draws', async () => {
expect(await renderedText(['heatmap'])).toMatch(/chart type is not supported yet/);
});

it('every offered type has its Add-menu name and its Type-select name in en and zh', () => {
for (const meta of Object.values(WIDGET_TYPE_META)) {
for (const key of [meta.labelKey, meta.selectLabelKey]) {
expect(t(key, 'en-US'), `${key}: missing en row`).not.toBe(key);
// A missing zh row reads the en one back.
expect(t(key, 'zh-CN'), `${key}: missing zh row`).not.toBe(t(key, 'en-US'));
}
// The Add menu's en name is the English the stored default title is written from.
expect(t(meta.labelKey, 'en-US'), meta.id).toBe(meta.label);
}
});
});
Loading
Loading