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
18 changes: 18 additions & 0 deletions .changeset/11520-object-chart-spectype-routing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@object-ui/plugin-charts': minor
---

fix(plugin-charts): an `object-chart` whose `specType` is a single-value or tabular spec family draws its routed form, not a silent bar chart; the renderer has no default family (objectui#11520)

**What an accepted document draws changes.** `specType` is the react tier's chart family: the react-page wrapper parks `<ObjectChart type="gauge">` there, because `type` is the node's discriminator. Both faces declare it as the spec's whole `ChartTypeSchema`, so an `object-chart` with `specType` set to `gauge`, `solid-gauge`, `metric`, `kpi`, `bullet`, `table` or `pivot` parsed on both faces, and drew a **bar chart**, with no note. The same families on `chartType` already drew the forms below.

- **`gauge`, `solid-gauge`, `metric`, `kpi`, `bullet` on `specType`** now draw the number card: one row's number, the form the same family draws on `chartType`.
- **`table`, `pivot` on `specType`** now draw the tabular notice, which names the data-table and pivot components.
- **An off-spec `specType`** (both faces refuse it) draws the unknown-type notice, which names the value.
- **A chart that names no family at all** (both faces refuse it: `object-chart` needs `chartType` or `specType`, and `chart` needs `chartType`) draws a notice reading "This chart names no chart type — nothing was drawn." It used to draw a bar chart. A caller that means a bar names `bar`.

Every family this block draws as a chart draws exactly what it drew before, on both keys.

How: `normalizeChartSchema` used to keep only the families this package draws as a chart, and `AdvancedChartImpl` drew a missing family as `'bar'`. The normalizer now hands the named family on as named, so `specType` reaches the same family dispatch `chartType` reaches, and the `'bar'` default is removed (with its four copies in the refusal guards).

⚠️ **Type.** `NormalizedChartSchema['chartType']`, exported from this package, widens from the thirteen drawn families to the declared chart families (`DeclaredChartFamily` in the emitted declarations; not a new entry export): `object-chart`'s `chartType` union together with its `specType` (the spec's `ChartType`), both read off `ObjectChartSchema` in `@object-ui/types` by reference. It is now the family the schema names, drawn or not, and a misspelled family still fails to compile. Shipped as `minor`, not `major` (objectui never declares `major`); a consumer that reads it exhaustively needs a branch for each single-value and tabular family, which this package draws no chart of.
109 changes: 75 additions & 34 deletions packages/plugin-charts/src/AdvancedChartImpl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import {
ChartContainerConfig
} from './ChartContainerImpl';
import { mapScatterClick, mapTreemapClick, mapSankeyClick } from './chartDrillEvents';
import { formatterFor, domainFor, ticksFor, RENDERABLE, SINGLE_VALUE_CHART_TYPES, TABULAR_CHART_TYPES, effectiveChartFamily, comboBaseFamily, placeYAxes, type NormalizedAxis, type NormalizedSeries, type ValueAxisSlot, type YAxisPlacement } from './normalizeChartSchema';
import { formatterFor, domainFor, ticksFor, RENDERABLE, SINGLE_VALUE_CHART_TYPES, TABULAR_CHART_TYPES, effectiveChartFamily, comboBaseFamily, placeYAxes, type DeclaredChartFamily, type NormalizedAxis, type NormalizedSeries, type ValueAxisSlot, type YAxisPlacement } from './normalizeChartSchema';
import { buildCategoryRank, chartRowBucketId, isRealCalendarDate, toDateInputValue, toDisplayDate, type ChartSegmentClickEvent } from '@object-ui/core';
import { useDisplayLocale, useSafeTranslate } from '@object-ui/i18n';

Expand Down Expand Up @@ -238,11 +238,22 @@ const seriesLabelForKey = (

export interface AdvancedChartImplProps {
/**
* Chart family. `combo` is renderer-local and rarely needs to be passed:
* series declaring different families derive it (`effectiveChartFamily`),
* which is how `@objectstack/spec` expresses a combo chart.
* Chart family, as the schema names it. `combo` is renderer-local and rarely
* needs to be passed: series declaring different families derive it
* (`effectiveChartFamily`), which is how `@objectstack/spec` expresses a
* combo chart.
*
* Typed as the declared chart families ({@link DeclaredChartFamily}: the
* spec's `ChartType` and the `object-chart` node's own union, by reference),
* so a misspelled family does not compile. The family dispatch below is the
* one place a family becomes a form: a chart for `RENDERABLE`, the number
* card for the single-value families, the tabular notice for the tabular
* ones, and a notice for anything else, which is how a value from unvalidated
* JSON outside the type is answered at runtime. ⛔ There is no default family
* (objectui#11520): an absent family is that notice too, never a bar. A
* caller that means a bar passes `'bar'`.
*/
chartType?: 'bar' | 'column' | 'horizontal-bar' | 'line' | 'area' | 'pie' | 'donut' | 'radar' | 'scatter' | 'funnel' | 'combo' | 'treemap' | 'sankey';
chartType?: DeclaredChartFamily;
data?: Array<Record<string, any>>;
config?: ChartContainerConfig;
xAxisKey?: string;
Expand Down Expand Up @@ -1426,7 +1437,10 @@ function unplottedPointsNote(
* This component is lazy-loaded to avoid including Recharts in the initial bundle
*/
function AdvancedChartImplInner({
chartType: rawChartType = 'bar',
// ⛔ No `= 'bar'` default (objectui#11520): it drew a bar for every family
// that arrived unset, which is how a `specType: 'gauge'` chart became a bar
// chart with no note. An absent family is the notice in the dispatch below.
chartType: rawChartType,
data: rawData = [],
config = {},
xAxisKey = 'name',
Expand Down Expand Up @@ -1676,22 +1690,6 @@ function AdvancedChartImplInner({
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
const ChartComponent = {
bar: BarChart,
'horizontal-bar': BarChart,
line: LineChart,
area: AreaChart,
pie: PieChart,
donut: PieChart,
radar: RadarChart,
scatter: ScatterChart,
funnel: FunnelChart as any,
// combo/treemap/sankey return from their own branches above; mapped here
// only so the index type stays exhaustive.
combo: ComposedChart,
treemap: BarChart,
sankey: BarChart,
}[chartType] || BarChart;

// Format ISO date strings into compact "MMM D" / "MMM YYYY" labels for X-axis ticks.
// Falls back to the raw value when not parseable as a date.
Expand Down Expand Up @@ -2038,12 +2036,17 @@ function AdvancedChartImplInner({
// #2942 — the non-series spec families used to fall through the component
// map's `|| BarChart` into a bar shell whose series marks all returned
// null: grid, axes, tooltip and legend rendered with NO data marks,
// indistinguishable from an empty dataset. Reachable because ChartRenderer
// resolves `schema.chartType ?? spec.chartType` without going through
// `normalizeChartSchema`'s RENDERABLE gate. Single-value families render
// indistinguishable from an empty dataset. Single-value families render
// the measure as a number (the spec's own framing for them); tabular ones
// say which component owns the rendering; unknown values are named instead
// of guessed at.
//
// objectui#11520 — this is the ONE family → form dispatch, and every channel
// reaches it with the family as named: `chartType` straight from
// `ChartRenderer`, and `specType` (the react tier's family) through
// `normalizeChartSchema`, which no longer drops the families this block
// draws no chart of. A chart that arrives with no family at all is the
// unknown-type notice below, not a bar.
if (chartType && SINGLE_VALUE_CHART_TYPES.has(chartType)) {
const dataKey = series[0]?.dataKey || 'value';
const raw = data[0]?.[dataKey];
Expand Down Expand Up @@ -2071,18 +2074,43 @@ function AdvancedChartImplInner({
</div>
);
}
if (chartType && !RENDERABLE.has(chartType)) {
if (!chartType || !RENDERABLE.has(chartType)) {
return (
<div
className={`rounded-md border border-dashed bg-muted/20 px-3 py-2 text-xs text-muted-foreground ${className ?? ''}`}
data-testid="advanced-chart-unknown-type"
role="note"
>
Chart type &ldquo;{chartType}&rdquo; is not a spec chart type — nothing was drawn.
{chartType ? (
<>Chart type &ldquo;{chartType}&rdquo; is not a spec chart type — nothing was drawn.</>
) : (
<>This chart names no chart type — nothing was drawn.</>
)}
</div>
);
}

// The recharts root the cartesian tail at the end draws in. It is read
// only past the dispatch above, which returns for every family outside
// `RENDERABLE` and for none (objectui#11520), so `chartType` is a drawn
// family here. combo/treemap/sankey return from their own branches below;
// they are mapped only so every drawn family has a row.
const cartesianRoots = {
bar: BarChart,
'horizontal-bar': BarChart,
line: LineChart,
area: AreaChart,
pie: PieChart,
donut: PieChart,
radar: RadarChart,
scatter: ScatterChart,
funnel: FunnelChart as any,
combo: ComposedChart,
treemap: BarChart,
sankey: BarChart,
};
const ChartComponent = cartesianRoots[chartType as keyof typeof cartesianRoots] || BarChart;

// Pie and Donut charts
if (chartType === 'pie' || chartType === 'donut') {
const innerRadius = chartType === 'donut' ? '52%' : 0;
Expand Down Expand Up @@ -2916,11 +2944,24 @@ function AdvancedChartImplInner({
* it (see `bucketNullCategories` in `@object-ui/core`), which is what keeps this
* predicate meaning what it says.
*/
/**
* The family the refusal guards below judge, `column` read as the bar it draws.
*
* ⛔ No `'bar'` default (objectui#11520). Each guard used to restate the
* component's own `= 'bar'`, so a chart that named no family was judged as a
* bar here before it was drawn as one. It now names no family to them either:
* none of them refuses it, and the component draws the unknown-type notice.
*/
function guardFamily(props: AdvancedChartImplProps): DeclaredChartFamily | undefined {
return props.chartType === 'column' ? 'bar' : props.chartType;
}

function hasNoCategoryKey(props: AdvancedChartImplProps): boolean {
const chartType = props.chartType === 'column' ? 'bar' : (props.chartType ?? 'bar');
const chartType = guardFamily(props);
const rows = Array.isArray(props.data) ? props.data : [];
const key = props.xAxisKey ?? 'name';
return (
chartType !== undefined &&
CATEGORY_AXIS_CHART_TYPES.has(chartType) &&
rows.length > 0 &&
!rows.some((row) => row != null && typeof row === 'object' && key in row)
Expand Down Expand Up @@ -3019,9 +3060,9 @@ const SERIES_ONLY_CHART_TYPES: ReadonlySet<string> = new Set([
type NoPlottableSeries = 'empty' | 'undeclared';

function hasNoPlottableSeries(props: AdvancedChartImplProps): NoPlottableSeries | null {
const chartType = props.chartType === 'column' ? 'bar' : (props.chartType ?? 'bar');
const chartType = guardFamily(props);
const rows = Array.isArray(props.data) ? props.data : [];
if (!SERIES_ONLY_CHART_TYPES.has(chartType) || rows.length === 0) return null;
if (chartType === undefined || !SERIES_ONLY_CHART_TYPES.has(chartType) || rows.length === 0) return null;
if (props.series === undefined) return 'undeclared';
if (Array.isArray(props.series) && props.series.length === 0) return 'empty';
return null;
Expand Down Expand Up @@ -3079,10 +3120,10 @@ function hasNoPlottableSeries(props: AdvancedChartImplProps): NoPlottableSeries
* counts.
*/
function hasNoNumericSeriesValue(props: AdvancedChartImplProps): string[] | null {
const chartType = props.chartType === 'column' ? 'bar' : (props.chartType ?? 'bar');
const chartType = guardFamily(props);
const rows = Array.isArray(props.data) ? props.data : [];
const series = Array.isArray(props.series) ? props.series : [];
if (!SERIES_ONLY_CHART_TYPES.has(chartType) || rows.length === 0 || series.length === 0) return null;
if (chartType === undefined || !SERIES_ONLY_CHART_TYPES.has(chartType) || rows.length === 0 || series.length === 0) return null;
const keys = Array.from(new Set(series.map((s) => String(s.dataKey))));
const resolved = keys.every((key) =>
rows.some(
Expand Down Expand Up @@ -3145,10 +3186,10 @@ function hasNoNumericSeriesValue(props: AdvancedChartImplProps): string[] | null
* at all". Scatter's absent key is already refused by `no-plottable-points`.
*/
function hasNoCarriedSeriesKey(props: AdvancedChartImplProps): string[] | null {
const chartType = props.chartType === 'column' ? 'bar' : (props.chartType ?? 'bar');
const chartType = guardFamily(props);
const rows = Array.isArray(props.data) ? props.data : [];
const series = Array.isArray(props.series) ? props.series : [];
if (!SERIES_ONLY_CHART_TYPES.has(chartType) || rows.length === 0 || series.length === 0) return null;
if (chartType === undefined || !SERIES_ONLY_CHART_TYPES.has(chartType) || rows.length === 0 || series.length === 0) return null;
const keys: unknown[] = Array.from(new Set(series.map((s) => s.dataKey)));
const absent = keys.every(
(key) =>
Expand Down
Loading
Loading