diff --git a/.changeset/dashboard-inline-chartconfig-4044.md b/.changeset/dashboard-inline-chartconfig-4044.md
new file mode 100644
index 0000000000..66771e9a42
--- /dev/null
+++ b/.changeset/dashboard-inline-chartconfig-4044.md
@@ -0,0 +1,13 @@
+---
+'@object-ui/plugin-dashboard': minor
+---
+
+dashboard: honour a widget's declared `chartConfig` on the inline chart relays, not only on the dataset path
+
+`DashboardWidget.chartConfig` is declared as the spec's full `ChartConfigSchema` on **every** dashboard widget, but only the ADR-0021 dataset path (`DatasetWidget`) read it. The two inline relays — `DashboardRenderer` and `DashboardGridLayout`, which compose the chart node for a widget bound to inline rows or to a `provider: 'object'` aggregate — mentioned `chartConfig` zero times, so an author who wrote `chartConfig.title` / `.subtitle` / `.description` / `.colors` / `.height` / `.showLegend` / `.showDataLabels` / `.annotations` / `.interaction` on such a widget parsed clean and got nothing on screen.
+
+Both relays now lower those keys through the same `chartConfigPresentation` whitelist `DatasetWidget` uses (`@object-ui/core`), so one authored chart config means the same thing on every dashboard surface.
+
+**Behaviour change, stated explicitly** — this is why the bump is `minor` and not a patch: a dashboard whose stored metadata ALREADY carries `chartConfig` on an inline-bound chart widget renders differently after this change. It draws the authored titles, accessible description, palette, plot height, data labels, annotations and interaction toggles that were previously dropped. Widgets that declare no `chartConfig` compose exactly what they composed before.
+
+Five of the fourteen declared keys are still not forwarded, each for a measured reason. `type` is refused because the widget's own `type` already picks the chart family on this path. `xAxis` / `yAxis` / `series` are refused because whether an authored axis beats the dataset-derived one is an open protocol question, filed for the spec seat as objectstack-ai/objectstack#17385. `aria` is refused because nothing on this path reads it in EITHER spelling — measured by forwarding it anyway, as the nested object and again flattened onto the node's own `ariaLabel` / `ariaDescribedBy` / `role`: neither changed a single attribute on screen, because `ChartRenderer` drops every prop but `schema` and `onChartClick`. Delivering it needs a reader inside `@object-ui/plugin-charts`, which is a separate decision.
diff --git a/packages/plugin-charts/src/ChartRenderer.dashboardChartConfig.test.tsx b/packages/plugin-charts/src/ChartRenderer.dashboardChartConfig.test.tsx
index 3b527da70c..3a36dee8f1 100644
--- a/packages/plugin-charts/src/ChartRenderer.dashboardChartConfig.test.tsx
+++ b/packages/plugin-charts/src/ChartRenderer.dashboardChartConfig.test.tsx
@@ -27,6 +27,20 @@
* produces it is pinned in plugin-dashboard's
* `DatasetWidget.chartConfig.test.tsx`; together the two close the loop from
* dashboard metadata to drawn pixels.
+ *
+ * ⭐ What this file is NOT (objectui#4044). Since that card the two INLINE
+ * dashboard relays (`DashboardRenderer` and `DashboardGridLayout`, for widgets
+ * bound to inline rows or to a `provider: 'object'` aggregate rather than to an
+ * ADR-0021 dataset) lower the same keys through the same
+ * `chartConfigPresentation` whitelist onto a node of the same shape. It is
+ * tempting to read the assertions below as covering those relays too. They do
+ * not, and the difference was measured: with the forwarding deleted from BOTH
+ * relays, every test in this file still passed — because the schema above is
+ * hand-built here rather than composed by a relay. What this file pins is the
+ * CHART BLOCK: that a node carrying these keys draws them. The dashboard
+ * surface pins its own end of the chain, in plugin-dashboard's
+ * `DashboardChart.chartConfig-4044.test.tsx` (the seam) and its two
+ * end-to-end siblings `…chartConfigDom-4044` and `…chartConfigMarks-4044`.
*/
import React from 'react';
@@ -194,6 +208,41 @@ describe('dashboard chartConfig — interaction (objectstack#7016)', () => {
});
});
+describe('dashboard chartConfig — showLegend (objectui#4044)', () => {
+ // #3135 lowered this flag on the dataset path and objectui#4044 lowers it on
+ // the two inline relays, but it never had a DRAWN pin here — only seam ones.
+ // A pie is used because it draws one legend entry per CATEGORY, so the
+ // legend's presence is readable without a second series.
+ //
+ // Recharts registers the legend payload from a layout effect and the Legend
+ // re-renders off that store update, so the legend text arrives a tick after
+ // the surface does — hence `waitFor` rather than a read straight after
+ // `plotted`.
+ const legendText = (c: HTMLElement) => c.querySelector('.recharts-legend-wrapper')?.textContent ?? '';
+
+ it('draws the legend when undeclared (the schema default) and when explicitly on', async () => {
+ const { container: bare } = render();
+ await plotted(bare);
+ await waitFor(() => expect(legendText(bare)).toContain('open'));
+ cleanup();
+ const { container: on } = render(
+ ,
+ );
+ await plotted(on);
+ await waitFor(() => expect(legendText(on)).toContain('open'));
+ });
+
+ it('draws no legend when showLegend is false', async () => {
+ // `plotted` first: an empty legend has to mean "the plot drew and chose not
+ // to legend it", never "nothing rendered yet".
+ const { container } = render(
+ ,
+ );
+ await plotted(container);
+ expect(container.querySelector('.recharts-legend-wrapper')).toBeNull();
+ });
+});
+
describe('dashboard chartConfig — the keys that stay out (objectstack#7016)', () => {
// `aria` is declared by ChartConfigSchema and read by NOTHING on this path, so
// DatasetWidget refuses to lower it. This pins the "read by nothing" half: even
diff --git a/packages/plugin-dashboard/src/DashboardGridLayout.tsx b/packages/plugin-dashboard/src/DashboardGridLayout.tsx
index 492907cd1d..61a4955197 100644
--- a/packages/plugin-dashboard/src/DashboardGridLayout.tsx
+++ b/packages/plugin-dashboard/src/DashboardGridLayout.tsx
@@ -6,7 +6,7 @@ import { Edit, GripVertical, Save, X, RefreshCw } from 'lucide-react';
import { SchemaRenderer, useHasDndProvider, useDnd } from '@object-ui/react';
import { useObjectTranslation, pickLocalized } from '@object-ui/i18n';
import type { BaseSchema, DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui/types';
-import { chartCategoryKey, chartMeasureKey } from '@object-ui/core';
+import { chartCategoryKey, chartConfigPresentation, chartMeasureKey } from '@object-ui/core';
import { isObjectProvider, deriveStaticTableColumns } from './utils';
import { classifyWidgetType } from './widgetDispatch';
import { LEGACY_RETIRED_WIDGET_SCHEMA, isLegacyRetiredWidget } from './legacyRetiredWidget';
@@ -246,6 +246,18 @@ export const DashboardGridLayout: React.FC = ({
const xAxisKey = options.xField || 'name';
const yField = options.yField || 'value';
+ // The widget's declared `chartConfig`, lowered onto the chart schema —
+ // objectui#4044, and the twin of the block in `DashboardRenderer`. This
+ // surface is the EDITABLE dashboard grid over the same stored widget
+ // metadata, so an author whose `chartConfig` drew nothing here but drew
+ // on the read-only renderer would read the difference as a bug in the
+ // editor. `isLegacyRetiredWidget` above is the settled precedent for the
+ // pair (objectui#4612): one shared implementation, imported rather than
+ // restated — here that shared implementation is core's
+ // `chartConfigPresentation`, the same whitelist `DatasetWidget` lowers
+ // through.
+ const chartPresentation = chartConfigPresentation(widget.chartConfig);
+
// provider: 'object' — delegate to ObjectChart for async data loading.
// Field/aggregate config comes from the nested data provider (the
// pre-ADR-0021 top-level analytics keys were retired in framework#3320).
@@ -284,7 +296,8 @@ export const DashboardGridLayout: React.FC = ({
colors: CHART_COLORS,
// Deterministic first paint inside the grid (#2756).
isAnimationActive: false,
- className: "h-full"
+ className: "h-full",
+ ...chartPresentation,
};
}
@@ -299,7 +312,8 @@ export const DashboardGridLayout: React.FC = ({
colors: CHART_COLORS,
// Deterministic first paint inside the grid (#2756).
isAnimationActive: false,
- className: "h-full"
+ className: "h-full",
+ ...chartPresentation,
};
}
diff --git a/packages/plugin-dashboard/src/DashboardRenderer.tsx b/packages/plugin-dashboard/src/DashboardRenderer.tsx
index f922e2a1f3..1e3866871a 100644
--- a/packages/plugin-dashboard/src/DashboardRenderer.tsx
+++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx
@@ -18,6 +18,7 @@ import {
toDomProps,
chartCategoryKey,
chartMeasureKey,
+ chartConfigPresentation,
} from '@object-ui/core';
import { cn, Card, CardHeader, CardTitle, CardContent, Button, getLazyIcon } from '@object-ui/components';
import { forwardRef, useState, useEffect, useCallback, useMemo, useRef, Fragment } from 'react';
@@ -639,6 +640,28 @@ const DashboardRendererInner = forwardRef {
+ composed.push(props.schema ?? props);
+ return null;
+};
+for (const type of ['object-chart', 'chart'] as const) {
+ ComponentRegistry.register(type, recorder as any, {
+ namespace: 'test',
+ label: 'recorder',
+ category: 'plugin',
+ } as any);
+}
+
+afterEach(cleanup);
+
+const dataSource = { aggregate: async () => [], find: async () => [] };
+
+/** Render one widget through a relay and return the node it composed. */
+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 ROWS = [
+ { status: 'open', total: 120 },
+ { status: 'paid', total: 80 },
+];
+
+/** The two INLINE binding shapes, neither of which is an ADR-0021 dataset. */
+const BRANCHES = [
+ {
+ name: 'inline rows',
+ expectedType: 'chart',
+ widget: (chartConfig?: Record) => ({
+ id: 'w1',
+ type: 'bar',
+ title: 'Invoices',
+ options: { xField: 'status', yField: 'total' },
+ data: ROWS,
+ ...(chartConfig ? { chartConfig } : {}),
+ }),
+ },
+ {
+ name: 'object provider',
+ expectedType: 'object-chart',
+ widget: (chartConfig?: Record) => ({
+ id: 'w2',
+ type: 'bar',
+ title: 'Invoices',
+ data: { provider: 'object', object: 'invoices', aggregate: { function: 'count', groupBy: 'status' } },
+ ...(chartConfig ? { chartConfig } : {}),
+ }),
+ },
+] as const;
+
+const SURFACES = ['grid', 'renderer'] as const;
+
+const CASES = SURFACES.flatMap((surface) => BRANCHES.map((branch) => ({ surface, branch })));
+
+describe.each(CASES)('$surface relay, $branch.name — chartConfig lowered (objectui#4044)', ({ surface, branch }) => {
+ const compose = (chartConfig?: Record) => composeVia(surface, branch.widget(chartConfig));
+
+ it('composes the branch this case is about', async () => {
+ // Guards every assertion below against silently measuring the other branch:
+ // both widgets carry `type: 'bar'`, and only the data shape forks them.
+ expect((await compose()).type).toBe(branch.expectedType);
+ });
+
+ it('forwards the chart titles and the accessibility description', async () => {
+ const node = await compose({
+ title: 'Invoice value',
+ subtitle: 'by status',
+ description: 'Invoice value by status',
+ });
+ expect(node.title).toBe('Invoice value');
+ expect(node.subtitle).toBe('by status');
+ expect(node.description).toBe('Invoice value by status');
+ });
+
+ it('forwards an explicit plot height and drops a non-positive one', async () => {
+ expect((await compose({ height: 420 })).height).toBe(420);
+ expect('height' in (await compose({ height: 0 }))).toBe(false);
+ expect('height' in (await compose({ height: -10 }))).toBe(false);
+ });
+
+ it('forwards showLegend and showDataLabels in both directions', async () => {
+ expect((await compose({ showLegend: false })).showLegend).toBe(false);
+ expect((await compose({ showLegend: true })).showLegend).toBe(true);
+ expect((await compose({ showDataLabels: true })).showDataLabels).toBe(true);
+ expect((await compose({ showDataLabels: false })).showDataLabels).toBe(false);
+ });
+
+ it('forwards annotations and the interaction toggles', async () => {
+ const node = await compose({
+ annotations: [{ type: 'line', axis: 'y', value: 100, label: 'Target' }],
+ interaction: { tooltips: false, brush: true },
+ });
+ expect(node.annotations).toEqual([{ type: 'line', axis: 'y', value: 100, label: 'Target' }]);
+ expect(node.interaction).toEqual({ tooltips: false, brush: true });
+ });
+
+ it('drops an empty annotations array instead of emitting a dead key', async () => {
+ expect('annotations' in (await compose({ annotations: [] }))).toBe(false);
+ });
+
+ it('lets an array `colors` override the relay default palette', async () => {
+ // This relay pre-sets `colors: CHART_COLORS`, so "the author's palette
+ // wins" is a real question here that the dataset path never had to answer.
+ expect((await compose({ colors: ['#111111', '#222222'] })).colors).toEqual(['#111111', '#222222']);
+ });
+
+ it('lowers a record `colors` as the per-category map, not as the palette', async () => {
+ const node = await compose({ colors: { open: '#10B981', paid: '#EF4444' } });
+ expect(node.categoryColors).toEqual({ open: '#10B981', paid: '#EF4444' });
+ // The positional palette is untouched: a per-category map is consulted
+ // FIRST and falls back to the palette, so replacing the default would
+ // change the colour of every category the map does not name.
+ expect(node.colors).not.toEqual({ open: '#10B981', paid: '#EF4444' });
+ });
+
+ it('emits none of the presentation keys when no chartConfig is declared', async () => {
+ // The whole point of a whitelist: every dashboard that never wrote
+ // `chartConfig` composes exactly what it composed before this card.
+ const node = await compose();
+ for (const key of [
+ 'title', 'subtitle', 'description', 'height', 'categoryColors',
+ 'showLegend', 'showDataLabels', 'annotations', 'interaction',
+ ]) {
+ expect({ key, present: key in node }).toEqual({ key, present: false });
+ }
+ expect(Array.isArray(node.colors)).toBe(true);
+ });
+});
+
+describe.each(CASES)('$surface relay, $branch.name — chartConfig keys REFUSED (objectui#4044)', ({ surface, branch }) => {
+ const compose = (chartConfig?: Record) => composeVia(surface, branch.widget(chartConfig));
+
+ it('emits no xAxis / yAxis / series from chartConfig, leaving the derivation alone', async () => {
+ // objectstack#17385 owns the precedence between an authored axis and the
+ // derived one. Until it answers, the three keys must not travel at all —
+ // an implementation that guessed would pre-empt the protocol decision.
+ const before = await compose();
+ const node = await compose({
+ xAxis: { field: 'not_a_column', title: 'Authored X' },
+ yAxis: [{ field: 'not_a_measure', min: 0, max: 5 }],
+ series: [{ name: 'not_a_measure', stack: 'g' }],
+ });
+ expect('xAxis' in node).toBe(false);
+ expect('yAxis' in node).toBe(false);
+ expect(node.series).toEqual(before.series);
+ expect(node.xAxisKey).toBe(before.xAxisKey);
+ });
+
+ it('emits no chart family from chartConfig.type — the widget type still picks it', async () => {
+ const node = await compose({ type: 'pie' });
+ expect(node.chartType).toBe('bar');
+ });
+
+ it('ignores chartConfig.aria, which nothing on this path reads', async () => {
+ // `aria` IS on the ruling's DO-NOW list, so the refusal is measured rather
+ // than assumed. Two ablations, both run on this tree: forwarding `aria` as
+ // the nested spec object, and forwarding it FLATTENED onto the node's own
+ // `ariaLabel` / `ariaDescribedBy` / `role` — members `BaseSchema` already
+ // declares and `SchemaRenderer` already turns into DOM attributes, so the
+ // flattened route needs no new declaration anywhere. In BOTH runs the only
+ // red was this assertion; the DOM sibling's
+ // `an authored chartConfig.aria reaches no attribute on this surface`
+ // stayed green, i.e. the forwarded key still changed nothing on screen.
+ // `ChartRenderer` destructures `{ schema, onChartClick }` and drops the
+ // rest, `AdvancedChartImpl` declares no `aria` prop, and
+ // `normalizeChartSchema` names neither; the chart's one accessible name
+ // comes from `description` (`role="img"` + `aria-label`, pinned there).
+ //
+ // Delivering `aria` therefore needs a READER inside `@object-ui/plugin-charts`
+ // — a new member on a published face — which this card is not authorised to
+ // add, and which also has to answer to the accessible name `description`
+ // already sets. Reported rather than guessed.
+ const node = await compose({ aria: { ariaLabel: 'Authored name', role: 'figure' } });
+ expect('aria' in node).toBe(false);
+ expect('ariaLabel' in node).toBe(false);
+ expect('role' in node).toBe(false);
+ });
+});
diff --git a/packages/plugin-dashboard/src/__tests__/DashboardChart.chartConfigDom-4044.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardChart.chartConfigDom-4044.test.tsx
new file mode 100644
index 0000000000..f077752481
--- /dev/null
+++ b/packages/plugin-dashboard/src/__tests__/DashboardChart.chartConfigDom-4044.test.tsx
@@ -0,0 +1,190 @@
+/**
+ * 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#4044 — END TO END on the INLINE dashboard chart relays: widget
+ * metadata → real chart DOM.
+ *
+ * The sibling `DashboardChart.chartConfig-4044.test.tsx` pins WHICH keys the
+ * two relays compose. That is necessary and not sufficient: the criterion for
+ * lowering a `chartConfig` key is that the chart block DRAWS it, and a seam
+ * assertion cannot tell a honoured prop from an ignored one — forwarding a prop
+ * nobody reads would only move declared-but-not-delivered one layer down. So
+ * this file renders the REAL chain with no renderer stub at all —
+ * `DashboardRenderer` / `dashboard-grid` → `SchemaRenderer` → the registry's
+ * `chart` (`ChartRenderer`) → `AdvancedChartImpl` — and reads the resulting DOM.
+ *
+ * Scope of this file: everything the chart draws OUTSIDE Recharts'
+ * `ResponsiveContainer` — the ChartFrame titles, and the chart container's
+ * height and accessible name. Recharts' own marks (bars, LabelList, reference
+ * lines, Brush) need a measured box, which this file does not arrange, so they
+ * are asserted — also on the dashboard surface, also end to end — in
+ * `DashboardChart.chartConfigMarks-4044.test.tsx` beside this one, which sizes
+ * the `ResponsiveContainer` element itself.
+ *
+ * ⚠️ This paragraph used to say the marks could only be pinned inside
+ * `plugin-charts` (whose `ChartRenderer.dashboardChartConfig.test.tsx` mocks
+ * `ResponsiveContainer`), because `recharts` resolves in that package alone.
+ * The premise is still true — re-measured, `require.resolve('recharts')` from
+ * `packages/plugin-dashboard` is MODULE_NOT_FOUND — but the CONCLUSION was
+ * wrong, and wrong in the expensive direction: it left every plot-internal key
+ * with no dashboard-surface pin at all, so the relays could stop forwarding
+ * them and the only drawn evidence (in a file that hand-builds its own schema)
+ * would stay green. A `recharts` mock is not the only way to give the plot a
+ * box; see the sibling file for the one that needs no module mock.
+ *
+ * The widget below binds INLINE ROWS — deliberately not an ADR-0021 dataset,
+ * which is the path `DatasetWidget.chartConfig.dom.test.tsx` already covers.
+ * Both relays are exercised, because each composes its own chart node.
+ */
+
+import React from 'react';
+import { describe, it, expect, afterEach } from 'vitest';
+import { render, cleanup, screen, waitFor } from '@testing-library/react';
+import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
+import '@object-ui/components';
+// Registers `chart` in the ComponentRegistry, which is what `SchemaRenderer`
+// resolves the relay's `{ type: 'chart' }` node through. Production reaches
+// `AdvancedChartImpl` ONLY through the `React.lazy(() =>
+// import('./AdvancedChartImpl'))` factory inside `ChartRenderer`, so this test
+// reaches it the same way — by rendering the real chain and awaiting the
+// Suspense boundary.
+import '@object-ui/plugin-charts';
+import '../index';
+import { DashboardRenderer } from '../DashboardRenderer';
+
+afterEach(cleanup);
+
+const ROWS = [
+ { status: 'open', total: 120 },
+ { status: 'paid', total: 80 },
+];
+
+const dataSource = { aggregate: async () => [], find: async () => [] };
+
+const SURFACES = ['grid', 'renderer'] as const;
+
+/**
+ * Render one inline-rows chart widget through a relay and settle it.
+ *
+ * The chart container only exists once the lazy chart chunk has mounted — i.e.
+ * once the whole dashboard chart path really ran. Every witness in this file is
+ * post-boundary DOM by design (that is the point of the file), so the wait is
+ * BUDGETED rather than removed: AGENTS.md records first-`import()` latencies up
+ * to 976 ms under full parallelism, well past RTL's 1000 ms default once the
+ * recharts graph is cold. `waitFor` polls and returns as soon as the node
+ * appears, so a large timeout costs nothing when the chunk is warm.
+ */
+const renderWidget = async (surface: (typeof SURFACES)[number], chartConfig?: Record) => {
+ const widget = {
+ id: 'w1',
+ type: 'bar',
+ title: 'Invoices',
+ options: { xField: 'status', yField: 'total' },
+ data: ROWS,
+ ...(chartConfig ? { chartConfig } : {}),
+ };
+ const view = render(
+
+ {surface === 'grid' ? (
+
+ ) : (
+
+ )}
+ ,
+ );
+ await waitFor(() => expect(view.container.querySelector('[data-slot="chart"]')).not.toBeNull(), {
+ timeout: 15000,
+ });
+ return view;
+};
+
+const chartEl = (container: HTMLElement) => container.querySelector('[data-slot="chart"]') as HTMLElement;
+
+describe.each(SURFACES)('%s relay — inline chartConfig reaches the real chart DOM (objectui#4044)', (surface) => {
+ it('draws chartConfig.title / .subtitle above the plot', async () => {
+ const { container } = await renderWidget(surface, { title: 'Invoice value', subtitle: 'by status' });
+ const titleEl = screen.getByText('Invoice value');
+ const subtitleEl = screen.getByText('by status');
+ // Drawn by the CHART's own frame, not by the relay's card header — which is
+ // a real possibility to exclude, since the relay paints the widget's own
+ // `title` ('Invoices') there. `ChartFrame` wraps the chrome and the plot
+ // together and inserts one `min-h-0 flex-1` slot around the plot, so the
+ // plot's grandparent contains the chrome and the card header cannot.
+ const frame = chartEl(container).parentElement?.parentElement as HTMLElement;
+ expect(frame.contains(titleEl)).toBe(true);
+ expect(frame.contains(subtitleEl)).toBe(true);
+ });
+
+ it('adds no title chrome when chartConfig declares none', async () => {
+ const { container } = await renderWidget(surface);
+ expect(screen.queryByText('Invoice value')).toBeNull();
+ expect(screen.queryByText('by status')).toBeNull();
+ // `ChartFrame` is a passthrough with neither title nor subtitle, so the
+ // plot gains no wrapper at all — the structural counterpart of the case
+ // above, and what makes "no chrome" different from "empty chrome".
+ expect(container.querySelector('[data-slot="chart"]')).toBe(chartEl(container));
+ });
+
+ it('announces chartConfig.description as the chart graphic accessible name', async () => {
+ const { container } = await renderWidget(surface, { description: 'Invoice value by status' });
+ expect(chartEl(container).getAttribute('role')).toBe('img');
+ expect(chartEl(container).getAttribute('aria-label')).toBe('Invoice value by status');
+ });
+
+ it('leaves the graphic unlabelled when no description is declared', async () => {
+ // Not the same as an empty one: role="img" with no name is worse for a
+ // screen reader than a plain div it can skip past.
+ const { container } = await renderWidget(surface);
+ expect(chartEl(container).getAttribute('role')).toBeNull();
+ expect(chartEl(container).getAttribute('aria-label')).toBeNull();
+ });
+
+ it('applies chartConfig.height over the relay height class', async () => {
+ // The relay hands the chart a height UTILITY CLASS, and `cn` in
+ // `ChartContainerImpl` is a plain join rather than tailwind-merge, so the
+ // authored height cannot win by replacing that class — it wins because
+ // `AdvancedChartImpl` lowers it to an INLINE style on the same element.
+ // That is the fact this asserts, and it is specific to these relays: the
+ // dataset path carries no such class.
+ const { container } = await renderWidget(surface, { height: 420 });
+ expect(chartEl(container).style.height).toBe('420px');
+ });
+
+ it('keeps the relay height class in charge when none is declared', async () => {
+ const { container } = await renderWidget(surface);
+ expect(chartEl(container).style.height).toBe('');
+ });
+
+ // The one key on the ruling's DO-NOW list that is NOT forwarded, and this is
+ // the measurement behind that refusal rather than a pin of it.
+ //
+ // ⚠️ Read what it can and cannot fail for. It stays green whether or not the
+ // relay forwards `aria` — measured, by forwarding it on purpose in both
+ // spellings and re-running this file: with `aria` lowered as the nested spec
+ // object, and again with it FLATTENED onto the node's own `ariaLabel` /
+ // `ariaDescribedBy` / `role` (which `BaseSchema` already declares and
+ // `SchemaRenderer` already converts to DOM attributes), every assertion here
+ // still passed and only the seam refusal in the sibling file went red. That
+ // is the finding: an authored `aria` reaches no attribute on this surface in
+ // EITHER spelling, because `ChartRenderer` destructures `{ schema,
+ // onChartClick }` and drops the rest, so forwarding it would move
+ // declared-but-not-delivered one layer down instead of delivering it.
+ //
+ // The control that makes this readable is four assertions up: `description`,
+ // travelling the same whitelist onto the same node, DOES produce
+ // `role="img"` + `aria-label` here.
+ it('an authored chartConfig.aria reaches no attribute on this surface', async () => {
+ const { container } = await renderWidget(surface, {
+ aria: { ariaLabel: 'Authored name', role: 'figure' },
+ });
+ expect(container.querySelector('[aria-label]')).toBeNull();
+ expect(container.querySelector('[role="figure"]')).toBeNull();
+ expect(chartEl(container).getAttribute('role')).toBeNull();
+ });
+});
diff --git a/packages/plugin-dashboard/src/__tests__/DashboardChart.chartConfigMarks-4044.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardChart.chartConfigMarks-4044.test.tsx
new file mode 100644
index 0000000000..498f8a4e90
--- /dev/null
+++ b/packages/plugin-dashboard/src/__tests__/DashboardChart.chartConfigMarks-4044.test.tsx
@@ -0,0 +1,232 @@
+/**
+ * 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#4044 — the PLOT-INTERNAL half of the inline dashboard `chartConfig`
+ * forwarding, asserted **on the dashboard surface**: widget metadata in, drawn
+ * Recharts marks out, no renderer stub anywhere in the chain.
+ *
+ * ## Why this file exists beside the two others
+ *
+ * The card's ruling names the pin shape: every forwarded key gets a rendering
+ * assertion *on the dashboard surface* that reads it — never a "the prop was
+ * passed" assertion. `DashboardChart.chartConfig-4044.test.tsx` pins the seam
+ * (which keys the relays compose) and `DashboardChart.chartConfigDom-4044.test.tsx`
+ * pins the keys that paint OUTSIDE Recharts' `ResponsiveContainer`
+ * (`title` / `subtitle` / `description` / `height`). The remaining six —
+ * `colors`, the `categoryColors` arm of `colors`, `showDataLabels`,
+ * `annotations`, `interaction` and `showLegend` — paint *inside* it, and had no
+ * dashboard-surface pin at all: their only drawn evidence lived in
+ * `plugin-charts/src/ChartRenderer.dashboardChartConfig.test.tsx`, which
+ * hand-builds its schema and therefore stays GREEN when the relays stop
+ * forwarding. This file closes that gap.
+ *
+ * ## How the plot gets a box here, and why it is not a `recharts` mock
+ *
+ * The sibling files record that `recharts` resolves inside `plugin-charts`
+ * alone — re-measured, still true: `require.resolve('recharts')` from
+ * `packages/plugin-dashboard` is `MODULE_NOT_FOUND`, so `vi.mock('recharts')`
+ * is not available here. It is also not needed. `ResponsiveContainer` reads
+ * `containerRef.current.getBoundingClientRect()` SYNCHRONOUSLY inside its
+ * resize effect and seeds its size from that, consulting `ResizeObserver` only
+ * for later changes — and the repo's happy-dom `ResizeObserver` polyfill is a
+ * no-op, which is exactly why nothing painted before. Sizing that one element
+ * is therefore enough.
+ *
+ * ⚠️ SCOPED to the container element, never blanket — measured, because the
+ * blanket form looks like it works: with every element answering 480x320,
+ * Recharts' own axis-label measurement also reads 480x320, the x-axis claims
+ * the whole box, and the plot clip rect comes back `height="0"` with the marks
+ * absent while the `.recharts-surface` element is present. A file that waited
+ * on the surface and then asserted "no data labels" would have passed for that
+ * reason.
+ */
+
+import React from 'react';
+import { describe, it, expect, afterEach, beforeAll, afterAll } from 'vitest';
+import { render, cleanup, screen, waitFor } from '@testing-library/react';
+import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
+import '@object-ui/components';
+// Production reaches `AdvancedChartImpl` only through the `React.lazy(() =>
+// import('./AdvancedChartImpl'))` factory inside `ChartRenderer`; this import
+// registers the real `chart` entry and pays the recharts graph in the import
+// phase, which no test timeout applies to (AGENTS.md, 测试纪律).
+import '@object-ui/plugin-charts';
+import '../index';
+import { DashboardRenderer } from '../DashboardRenderer';
+
+const PLOT_BOX = { width: 480, height: 320 } as const;
+const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
+
+beforeAll(() => {
+ HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) {
+ if (this.classList?.contains('recharts-responsive-container')) {
+ return {
+ ...PLOT_BOX,
+ top: 0,
+ left: 0,
+ right: PLOT_BOX.width,
+ bottom: PLOT_BOX.height,
+ x: 0,
+ y: 0,
+ toJSON() {},
+ } as DOMRect;
+ }
+ return originalGetBoundingClientRect.call(this);
+ };
+});
+
+afterAll(() => {
+ HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
+});
+
+afterEach(cleanup);
+
+const ROWS = [
+ { status: 'open', total: 120 },
+ { status: 'paid', total: 80 },
+];
+
+const dataSource = { aggregate: async () => [], find: async () => [] };
+
+const SURFACES = ['grid', 'renderer'] as const;
+
+/**
+ * Render one INLINE-ROWS chart widget through a relay and settle it at the
+ * drawn plot — `.recharts-surface`, not the chart container, so every
+ * assertion below reads a plot that really painted. An empty result then means
+ * "the chart drew and chose not to", never "it had not drawn yet".
+ */
+const renderWidget = async (
+ surface: (typeof SURFACES)[number],
+ chartConfig?: Record,
+ widgetType: 'bar' | 'pie' = 'bar',
+) => {
+ const widget = {
+ id: 'w1',
+ type: widgetType,
+ title: 'Invoices',
+ options: { xField: 'status', yField: 'total' },
+ data: ROWS,
+ ...(chartConfig ? { chartConfig } : {}),
+ };
+ const view = render(
+
+ {surface === 'grid' ? (
+
+ ) : (
+
+ )}
+ ,
+ );
+ // AGENTS.md records first-`import()` latencies up to 976 ms under full
+ // parallelism, past RTL's 1000 ms default once the recharts graph is cold.
+ await waitFor(() => expect(view.container.querySelector('.recharts-surface')).not.toBeNull(), {
+ timeout: 15000,
+ });
+ return view;
+};
+
+const sectorFills = (c: HTMLElement) =>
+ Array.from(c.querySelectorAll('path.recharts-sector')).map((p) => p.getAttribute('fill'));
+const dataLabels = (c: HTMLElement) =>
+ Array.from(c.querySelectorAll('.recharts-label-list text')).map((t) => t.textContent);
+const legendText = (c: HTMLElement) => c.querySelector('.recharts-legend-wrapper')?.textContent ?? null;
+
+describe.each(SURFACES)('%s relay — inline chartConfig reaches the drawn marks (objectui#4044)', (surface) => {
+ it('paints the marks from an array `colors` palette', async () => {
+ // A pie draws one mark per CATEGORY, so a positional palette is readable
+ // straight off the sectors' fills — a bar's fill is a gradient `url(#…)`.
+ const { container } = await renderWidget(surface, { colors: ['#111111', '#222222'] }, 'pie');
+ expect(sectorFills(container)).toEqual(['#111111', '#222222']);
+ });
+
+ it('keeps the relay default palette when `colors` is undeclared', async () => {
+ // The control for the case above, and the reason it is not vacuous: the
+ // relay pre-sets `colors: CHART_COLORS` (the theme's `--chart-N` vars), so
+ // "the author's palette won" has to be told apart from "some palette was
+ // used".
+ const { container } = await renderWidget(surface, undefined, 'pie');
+ expect(sectorFills(container)).toEqual(['hsl(var(--chart-1))', 'hsl(var(--chart-2))']);
+ });
+
+ it('paints per-category colours from a record `colors` map', async () => {
+ // The record arm arrives as `categoryColors` (the whitelist splits it) and
+ // wins per category — the precedence the spec's own `colors` field states.
+ const { container } = await renderWidget(
+ surface,
+ { colors: { open: '#10B981', paid: '#EF4444' } },
+ 'pie',
+ );
+ expect(sectorFills(container)).toEqual(['#10B981', '#EF4444']);
+ });
+
+ it('prints each point value on the mark when showDataLabels is on', async () => {
+ const { container } = await renderWidget(surface, { showDataLabels: true });
+ expect(dataLabels(container)).toEqual(['120', '80']);
+ });
+
+ it('prints no data labels when showDataLabels is off or undeclared', async () => {
+ const { container: off } = await renderWidget(surface, { showDataLabels: false });
+ expect(dataLabels(off)).toEqual([]);
+ cleanup();
+ const { container: bare } = await renderWidget(surface);
+ expect(dataLabels(bare)).toEqual([]);
+ });
+
+ it('draws a reference line for a line annotation', async () => {
+ const { container } = await renderWidget(surface, {
+ annotations: [{ type: 'line', axis: 'y', value: 100, label: 'Target' }],
+ });
+ expect(container.querySelectorAll('.recharts-reference-line').length).toBeGreaterThan(0);
+ expect(screen.getByText('Target')).toBeTruthy();
+ });
+
+ it('draws a reference area for a region annotation', async () => {
+ const { container } = await renderWidget(surface, {
+ annotations: [{ type: 'region', axis: 'y', value: 50, endValue: 100 }],
+ });
+ expect(container.querySelectorAll('.recharts-reference-area').length).toBeGreaterThan(0);
+ });
+
+ it('draws no reference marks when no annotation is declared', async () => {
+ const { container } = await renderWidget(surface);
+ expect(container.querySelectorAll('.recharts-reference-line').length).toBe(0);
+ expect(container.querySelectorAll('.recharts-reference-area').length).toBe(0);
+ });
+
+ it('adds the range selector when interaction.brush is on, and not by default', async () => {
+ const { container: on } = await renderWidget(surface, { interaction: { brush: true } });
+ expect(on.querySelectorAll('.recharts-brush').length).toBeGreaterThan(0);
+ cleanup();
+ const { container: bare } = await renderWidget(surface);
+ expect(bare.querySelectorAll('.recharts-brush').length).toBe(0);
+ });
+
+ it('removes the hover tooltip when interaction.tooltips is false', async () => {
+ // The "on" arm is the control: without it a missing tooltip wrapper would
+ // read as honoured when it only meant the plot had not drawn.
+ const { container: on } = await renderWidget(surface);
+ expect(on.querySelectorAll('.recharts-tooltip-wrapper').length).toBeGreaterThan(0);
+ cleanup();
+ const { container: off } = await renderWidget(surface, { interaction: { tooltips: false } });
+ expect(off.querySelectorAll('.recharts-tooltip-wrapper').length).toBe(0);
+ });
+
+ it('draws no legend when showLegend is false, and draws one otherwise', async () => {
+ // A pie legends one entry per CATEGORY, so the legend's content is readable
+ // without a second series. Recharts registers the legend payload from a
+ // layout effect and the Legend re-renders off that store update, so the
+ // text arrives a tick after the surface — hence `waitFor`.
+ const { container: bare } = await renderWidget(surface, undefined, 'pie');
+ await waitFor(() => expect(legendText(bare)).toContain('open'));
+ cleanup();
+ const { container: off } = await renderWidget(surface, { showLegend: false }, 'pie');
+ expect(legendText(off)).toBeNull();
+ });
+});