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
51 changes: 51 additions & 0 deletions .changeset/dataset-widget-chartconfig-presentation-os7016.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
"@object-ui/plugin-dashboard": patch
---

Dashboard metadata's `chartConfig` presentation keys now take effect for the first time

`DashboardWidgetSchema.chartConfig` is declared as the full spec
`ChartConfigSchema`, but the ADR-0021 dataset path lowered exactly one key onto
the chart renderer: `showLegend` (objectui#3135). Everything else an author wrote
there — the chart's own `title`/`subtitle`, the accessibility `description`, an
explicit plot `height`, a `colors` palette or per-category colour map,
`showDataLabels`, `annotations`, `interaction` — parsed as valid metadata,
reached `DatasetWidget`, and was dropped before the chart schema was built. The
underlying chart block draws all of them; only the dashboard's hand-off was
missing.

`DatasetWidget` now lowers each of those keys, on two mechanical criteria, both
of which have to hold:

1. **The chart block draws it end to end on this path.** `{ type: 'chart' }`
resolves to `ChartRenderer` → `AdvancedChartImpl`, which draws
`title`/`subtitle` above the plot, turns `description` into the chart
container's `role="img"` + `aria-label`, applies `height` as that container's
inline height, paints `colors`, prints `showDataLabels` as per-point labels,
draws `annotations` as reference lines/bands and honours `interaction` as the
tooltip toggle plus the range selector. Each is pinned at the DOM level, so a
key is never forwarded to a prop that ignores it.
2. **It does not fight the dataset derivation.** `xAxis`, `yAxis` and `series`
are derived from the widget's dataset selection, so an authored one would
shadow the derived binding and blank the chart; they stay unforwarded, as does
`type` (the widget's own `type` already picks the chart family). `aria` stays
unforwarded too, for the other reason: nothing on this path reads it.

`colors` is split the way the react tier already splits it, because the two arms
reach the renderer through different props: a `string[]` is the positional
palette, a `{ value: color }` record is a per-category map merged over the
category dimension's own option colours.

**Behaviour-opening surface.** A dashboard that already wrote any of these keys
goes from having them ignored to having them applied — the point of the change,
but visible: a widget that declared `chartConfig.title` now shows that title
inside the plot area (in addition to the widget card's own `title`, which is a
separate key), one that declared `height` no longer fills its card, one that
declared `colors` stops using the theme palette, and `showDataLabels`,
`annotations` and `interaction.brush` start drawing. Widgets with no
`chartConfig`, or with only `showLegend`, render exactly as before: undeclared
keys are never emitted, so the renderer's own defaults stay in charge.

Part of objectstack#5175 (the enforce half); the narrowing half — what to do
about `aria`, and about `xAxis`/`yAxis`/`series` being declared on a surface that
derives them — is still open there.
216 changes: 216 additions & 0 deletions packages/plugin-charts/src/ChartRenderer.dashboardChartConfig.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
/**
* 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.
*/

/**
* objectstack#7016 — the plot-internal half of the dashboard `chartConfig`
* forwarding, pinned where it can actually be seen.
*
* `DatasetWidget` (plugin-dashboard) now lowers the `chartConfig` keys the chart
* block delivers onto the `{ type: 'chart' }` schema it hands to the renderer.
* The criterion for lowering a key is that the chart DRAWS it, so each one needs
* a DOM pin — and the marks below (bars, LabelList, ReferenceLine/Area, Brush)
* only exist once Recharts has a measured box. `ResponsiveContainer` reports 0×0
* under the headless DOM and renders no children, and `recharts` resolves inside
* THIS package alone, so the mock that fixes its size — and therefore this half
* of the evidence — has to live here.
*
* These render `ChartRenderer`, not `AdvancedChartImpl`: `ChartRenderer` is what
* the ComponentRegistry resolves `type: 'chart'` to, so it is the component the
* dashboard path actually reaches, and the schema below is byte-for-byte the
* shape `DatasetWidget` emits (derived `chartType`/`xAxisKey`/`series` +
* `isAnimationActive: false` + the lowered presentation keys). The seam that
* produces it is pinned in plugin-dashboard's
* `DatasetWidget.chartConfig.test.tsx`; together the two close the loop from
* dashboard metadata to drawn pixels.
*/

import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, screen, waitFor } from '@testing-library/react';

// Recharts' ResponsiveContainer measures via ResizeObserver, which reports 0×0
// under the headless DOM, so nothing paints. Fix its size.
vi.mock('recharts', async () => {
const actual = await vi.importActual<any>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: any) =>
React.cloneElement(children, { width: 480, height: 320 }),
};
});

// `ChartRenderer` renders its implementation behind
// `React.lazy(() => import('./AdvancedChartImpl'))`. Importing it here — with the
// SAME specifier, so the ESM cache satisfies the lazy factory — pays the recharts
// graph in the import phase, which no test timeout applies to (AGENTS.md §测试纪律).
import './AdvancedChartImpl';
import { ChartRenderer } from './ChartRenderer';

afterEach(cleanup);

/** The implementation is lazy — wait for the real plot, not the skeleton. */
const plotted = async (c: HTMLElement) => {
await waitFor(() => expect(c.querySelector('.recharts-surface')).toBeTruthy());
return c;
};

/**
* The chart schema a dataset-bound dashboard widget emits for
* `type: 'bar'`, `dimensions: ['status']`, `values: ['total']` — the derived
* bindings only. Presentation keys are spread in per test, exactly as
* `DatasetWidget` spreads its `chartConfig` presentation over this object.
*/
const dashboardSchema = (presentation: Record<string, unknown> = {}) => ({
type: 'chart',
chartType: 'bar' as const,
data: [
{ status: 'open', total: 120 },
{ status: 'paid', total: 80 },
],
xAxisKey: 'status',
series: [{ dataKey: 'total', label: 'Total' }],
isAnimationActive: false,
...presentation,
});

describe('dashboard chartConfig — colors (objectstack#7016)', () => {
const sectorFills = (container: HTMLElement) =>
Array.from(container.querySelectorAll('path.recharts-sector')).map((p) => p.getAttribute('fill'));

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.
const { container } = render(
<ChartRenderer
schema={dashboardSchema({ chartType: 'pie', colors: ['#111111', '#222222'] }) as any}
/>,
);
expect(sectorFills(await plotted(container))).toEqual(['#111111', '#222222']);
});

it('paints per-category colours from a record `colors` map, over the palette', async () => {
// The record arm of `colors` arrives as `categoryColors` (DatasetWidget does
// the split) and wins per category, which is the precedence the spec's own
// `colors` field comment states.
const { container } = render(
<ChartRenderer
schema={dashboardSchema({
chartType: 'pie',
colors: ['#111111', '#222222'],
categoryColors: { open: '#10B981', paid: '#EF4444' },
}) as any}
/>,
);
expect(sectorFills(await plotted(container))).toEqual(['#10B981', '#EF4444']);
});
});

describe('dashboard chartConfig — showDataLabels (objectstack#7016)', () => {
const labelTexts = (container: HTMLElement) =>
Array.from(container.querySelectorAll('.recharts-label-list text')).map((t) => t.textContent);

it('prints each point value on the mark when on', async () => {
const { container } = render(<ChartRenderer schema={dashboardSchema({ showDataLabels: true }) as any} />);
expect(labelTexts(await plotted(container))).toEqual(['120', '80']);
});

it('prints no data labels when off or undeclared', async () => {
// `plotted` first: an empty label list has to mean "the plot drew and chose
// not to label", never "nothing rendered yet".
const { container: off } = render(<ChartRenderer schema={dashboardSchema({ showDataLabels: false }) as any} />);
expect(labelTexts(await plotted(off))).toEqual([]);
cleanup();
const { container: bare } = render(<ChartRenderer schema={dashboardSchema() as any} />);
expect(labelTexts(await plotted(bare))).toEqual([]);
});
});

describe('dashboard chartConfig — annotations (objectstack#7016)', () => {
it('draws a reference line for a line annotation', async () => {
const { container } = render(
<ChartRenderer
schema={dashboardSchema({
annotations: [{ type: 'line', axis: 'y', value: 100, label: 'Target' }],
}) as any}
/>,
);
await plotted(container);
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 } = render(
<ChartRenderer
schema={dashboardSchema({
annotations: [{ type: 'region', axis: 'y', value: 50, endValue: 100 }],
}) as any}
/>,
);
await plotted(container);
expect(container.querySelectorAll('.recharts-reference-area').length).toBeGreaterThan(0);
});

it('draws nothing extra when no annotation is declared', async () => {
const { container } = render(<ChartRenderer schema={dashboardSchema() as any} />);
await plotted(container);
expect(container.querySelectorAll('.recharts-reference-line').length).toBe(0);
expect(container.querySelectorAll('.recharts-reference-area').length).toBe(0);
});
});

describe('dashboard chartConfig — interaction (objectstack#7016)', () => {
it('adds the range selector when interaction.brush is on', async () => {
const { container } = render(
<ChartRenderer schema={dashboardSchema({ interaction: { brush: true } }) as any} />,
);
await plotted(container);
expect(container.querySelectorAll('.recharts-brush').length).toBeGreaterThan(0);
});

it('omits the range selector by default', async () => {
const { container } = render(<ChartRenderer schema={dashboardSchema() as any} />);
await plotted(container);
expect(container.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 } = render(<ChartRenderer schema={dashboardSchema() as any} />);
await plotted(on);
expect(on.querySelectorAll('.recharts-tooltip-wrapper').length).toBeGreaterThan(0);
cleanup();
const { container: off } = render(
<ChartRenderer schema={dashboardSchema({ interaction: { tooltips: false } }) as any} />,
);
await plotted(off);
expect(off.querySelectorAll('.recharts-tooltip-wrapper').length).toBe(0);
});
});

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
// handed straight to the renderer the object changes no attribute, which is why
// forwarding it would only have moved declared-but-inert one layer down.
it('an `aria` object handed to the chart changes no attribute', async () => {
const { container } = render(
<ChartRenderer
schema={dashboardSchema({
aria: { ariaLabel: 'Authored name', ariaDescribedBy: 'hint', role: 'figure' },
}) as any}
/>,
);
await plotted(container);
const chart = container.querySelector('[data-slot="chart"]') as HTMLElement;
expect(chart.getAttribute('role')).toBeNull();
expect(chart.getAttribute('aria-label')).toBeNull();
expect(container.querySelector('[aria-describedby]')).toBeNull();
});
});
1 change: 1 addition & 0 deletions packages/plugin-dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"react-grid-layout": "^2.2.0 || ^1.4.0"
},
"devDependencies": {
"@object-ui/plugin-charts": "workspace:*",
"@objectstack/spec": "^17.0.0-rc.5",
"@types/react-grid-layout": "^2.1.0",
"@vitejs/plugin-react": "^6.0.5",
Expand Down
Loading
Loading