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
141 changes: 140 additions & 1 deletion packages/core/src/utils/__tests__/chart-series.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,12 @@
*/

import { describe, it, expect } from 'vitest';
import { buildOptionColorMap } from '../chart-series';
import {
buildOptionColorMap,
buildDimensionLabelMap,
relabelDimensions,
buildChartSeries,
} from '../chart-series';

describe('buildOptionColorMap', () => {
const health = [
Expand DownExpand Up@@ -52,3 +57,137 @@ describe('buildOptionColorMap', () => {
).toEqual({ c: '#0f0' });
});
});

describe('buildDimensionLabelMap', () => {
// The AI-build default: English stored `value` + localized display `label`.
const status = [
{ value: 'active', label: '合作中' },
{ value: 'lost', label: '已流失' },
{ value: 'potential', label: '潜在' },
];

it('maps each option value to its display label', () => {
expect(buildDimensionLabelMap(status)).toEqual({
active: '合作中',
lost: '已流失',
potential: '潜在',
});
});

it('drops no-op entries where the label equals the value (and bare strings)', () => {
// A select whose value IS its label needs no relabeling, so it must not
// appear in the map — that is what makes relabelDimensions idempotent.
expect(buildDimensionLabelMap([{ value: 'open', label: 'open' }, 'closed'])).toBeNull();
});

it('returns null for empty / missing / malformed options', () => {
expect(buildDimensionLabelMap(undefined)).toBeNull();
expect(buildDimensionLabelMap(null)).toBeNull();
expect(buildDimensionLabelMap([])).toBeNull();
expect(buildDimensionLabelMap([{ value: 'a' }, { label: 'b' }, null])).toBeNull();
});
});

describe('relabelDimensions', () => {
const maps = { status: { active: '合作中', lost: '已流失', potential: '潜在' } };

it('replaces a select dimension value with its label, keeping the measure attached', () => {
const rows = [
{ status: 'active', count: 6 },
{ status: 'lost', count: 2 },
{ status: 'potential', count: 4 },
];
expect(relabelDimensions(rows, maps)).toEqual([
{ status: '合作中', count: 6 },
{ status: '已流失', count: 2 },
{ status: '潜在', count: 4 },
]);
});

it('does not mutate the input rows (raw values survive for drill-through)', () => {
const rows = [{ status: 'active', count: 6 }];
relabelDimensions(rows, maps);
expect(rows[0].status).toBe('active');
});

it('passes through values with no mapping (already a label, lookup id, free text)', () => {
// Idempotent: running it again on already-resolved labels is a no-op.
const rows = [{ status: '合作中', count: 6 }, { status: 'archived', count: 1 }];
expect(relabelDimensions(rows, maps)).toEqual(rows);
});

it('is a no-op when there is no label map', () => {
const rows = [{ status: 'active', count: 6 }];
expect(relabelDimensions(rows, null)).toBe(rows);
expect(relabelDimensions(rows, {})).toBe(rows);
});

it('tolerates null/undefined rows', () => {
expect(relabelDimensions(null, maps)).toEqual([]);
expect(relabelDimensions(undefined, maps)).toEqual([]);
});
});

describe('relabelDimensions + buildChartSeries (the value≠label chart bug, cloud#667)', () => {
// A select field whose stored value is English and whose option label is
// Chinese — the default product of the AI build agent. The dataset groups by
// the stored VALUE (active/lost/potential), counts correct. Before the fix
// the chart axis read those raw values; the requirement is that the chart
// displays the LABEL while every count still lands on the right category.
const options = [
{ value: 'active', label: '合作中' },
{ value: 'lost', label: '已流失' },
{ value: 'potential', label: '潜在' },
];
const labelMaps = { status: buildDimensionLabelMap(options)! };

it('single dimension: counts land on label-keyed categories (no bar reads 0)', () => {
const valueKeyedRows = [
{ status: 'active', count: 6 },
{ status: 'lost', count: 2 },
{ status: 'potential', count: 4 },
];
const { data, xAxisKey, series } = buildChartSeries(
relabelDimensions(valueKeyedRows, labelMaps),
['status'],
['count'],
);
expect(xAxisKey).toBe('status');
expect(series).toEqual([{ dataKey: 'count', label: 'count' }]);
// Each category displays the label AND keeps its count (the chart reads
// data[i][xAxisKey] for the bar label and data[i][series.dataKey] for the
// height) — the exact mismatch that previously zeroed every bar.
expect(data).toEqual([
{ status: '合作中', count: 6 },
{ status: '已流失', count: 2 },
{ status: '潜在', count: 4 },
]);
const byCategory = Object.fromEntries(data.map((r) => [r.status, r.count]));
expect(byCategory).toEqual({ 合作中: 6, 已流失: 2, 潜在: 4 });
});

it('two dimensions: the pivoted grouped series read labels with counts intact', () => {
// month × status grouped count — status is the second (pivoted) dimension.
const rows = [
{ month: '2025-01', status: 'active', count: 5 },
{ month: '2025-01', status: 'lost', count: 1 },
{ month: '2025-02', status: 'active', count: 7 },
];
const { data, xAxisKey, series } = buildChartSeries(
relabelDimensions(rows, labelMaps),
['month', 'status'],
['count'],
);
expect(xAxisKey).toBe('month');
// Series (the second dimension) are keyed AND labeled by the display label,
// so the legend reads 合作中/已流失 and the pivoted column lookup matches.
expect(series).toEqual([
{ dataKey: '合作中', label: '合作中' },
{ dataKey: '已流失', label: '已流失' },
]);
expect(data).toEqual([
{ month: '2025-01', 合作中: 5, 已流失: 1 },
{ month: '2025-02', 合作中: 7 },
]);
});
});
75 changes: 75 additions & 0 deletions packages/core/src/utils/chart-series.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -86,6 +86,55 @@ export function buildChartSeries(
};
}

/**
* Resolve select/enum dimension VALUES to display LABELS in chart rows.
*
* Analytics groups by a select field's stored `value` (e.g. `active`), but a
* chart axis should read the option `label` (e.g. `合作中`). The server SHOULD
* resolve this (ADR-0021), but when it can't — an AI-built select whose
* `options` the analytics layer never sees, so its `resolveDimensionLabels`
* silently no-ops — the rows arrive value-keyed. The axis then shows raw enum
* values, and (worse) option-keyed colour / category wiring built from the
* field `label`s no longer lines up with the value-keyed rows, so categories
* read empty. This is the chart-layer safety net the legacy aggregate path
* already gets from `resolveGroupByLabels`.
*
* Each row is rewritten by replacing `row[dim]` with `labelMaps[dim][value]`
* when a mapping exists. Measure columns are untouched, so the grouped count
* stays attached to its (now label-keyed) category — `value` is the matching
* key, `label` is only the display. Values with no mapping (already a label
* because the server resolved it, a lookup id, free text) pass through, so this
* is safe to run unconditionally and is idempotent.
*
* Returns a NEW array; a row that needs no change keeps its identity, and the
* input rows are never mutated, so the server's raw rows survive for
* index-aligned drill-through (`drillRawRows`).
*/
export function relabelDimensions(
rows: Array<Record<string, unknown>> | null | undefined,
labelMaps: Record<string, Record<string, string>> | null | undefined,
): Array<Record<string, unknown>> {
const safeRows = Array.isArray(rows) ? rows : [];
if (!labelMaps) return safeRows;
const dims = Object.keys(labelMaps).filter(
(d) => labelMaps[d] && Object.keys(labelMaps[d]).length > 0,
);
if (dims.length === 0) return safeRows;
return safeRows.map((row) => {
let next: Record<string, unknown> | null = null;
for (const dim of dims) {
const raw = row[dim];
if (raw == null) continue;
const label = labelMaps[dim][String(raw)];
if (label != null && label !== raw) {
if (!next) next = { ...row };
next[dim] = label;
}
}
return next ?? row;
});
}

/**
* Inverse of {@link buildChartSeries}: map a clicked chart segment back to the
* index of its source dataset row, so a chart click can drill through to the
Expand DownExpand Up@@ -147,3 +196,29 @@ export function buildOptionColorMap(options: unknown): Record<string, string> |
}
return Object.keys(map).length > 0 ? map : null;
}

/**
* Build a `{ value → label }` map from a select/enum field's `options`, for
* resolving a grouped dimension's stored value to its display label (fed to
* {@link relabelDimensions}). Mirrors {@link buildOptionColorMap}.
*
* Options may be `{ value, label }` objects or bare strings (value == label —
* nothing to relabel). Only entries whose `label` actually differs from the
* `value` are kept, so the map is empty (→ `null`) when relabeling would be a
* no-op and the caller can skip it entirely.
*/
export function buildDimensionLabelMap(options: unknown): Record<string, string> | null {
if (!Array.isArray(options) || options.length === 0) return null;
const map: Record<string, string> = {};
for (const opt of options) {
if (opt && typeof opt === 'object') {
const o = opt as { value?: unknown; label?: unknown };
if (o.value != null && o.label != null) {
const v = String(o.value);
const l = String(o.label);
if (l !== v) map[v] = l;
}
}
}
return Object.keys(map).length > 0 ? map : null;
}
35 changes: 27 additions & 8 deletions packages/plugin-charts/src/ObjectChart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
import React, { useState, useEffect, useContext, useCallback, useMemo, useRef } from 'react';
import { useDataScope, SchemaRendererContext, SchemaRenderer, useDrillNavigation } from '@object-ui/react';
import { ChartRenderer } from './ChartRenderer';
import { ComponentRegistry, extractRecords, computeDrillFilter, isDrillEnabled, resolveDrillTitle, resolveDateMacros, shiftFilterByCompareTo, compareToTrendLabelKey, buildChartSeries, buildOptionColorMap, type CompareToConfig, type DrillEvent } from '@object-ui/core';
import { ComponentRegistry, extractRecords, computeDrillFilter, isDrillEnabled, resolveDrillTitle, resolveDateMacros, shiftFilterByCompareTo, compareToTrendLabelKey, buildChartSeries, buildOptionColorMap, buildDimensionLabelMap, relabelDimensions, type CompareToConfig, type DrillEvent } from '@object-ui/core';
import { Sheet, SheetContent, SheetHeader, SheetTitle, Dialog, DialogContent, DialogHeader, DialogTitle, RefreshIndicator, Button } from '@object-ui/components';
import { AlertCircle, ArrowUpRight } from 'lucide-react';
import { useSafeFieldLabel, useSafeTranslate } from '@object-ui/i18n';
Expand DownExpand Up@@ -262,6 +262,11 @@ export const ObjectChart = (props: any) => {
// layer can use them. Keyed by BOTH value and label since the row category
// may be either (server resolves dataset dimension labels).
const [fieldOptionColors, setFieldOptionColors] = useState<Record<string, string> | null>(null);
// Dataset path: {value → label} per dimension, so a value-keyed group (e.g.
// status=`active`) shows its option label (`合作中`) on the axis/legend with
// its count intact when the server returned raw values (cloud#667). The legacy
// objectName path already resolves labels via resolveGroupByLabels below.
const [dimensionLabels, setDimensionLabels] = useState<Record<string, Record<string, string>> | null>(null);
// Host-provided "open in list" navigation for the drill escape hatch.
const { openRecordList } = useDrillNavigation();
const tt = useSafeTranslate();
Expand DownExpand Up@@ -304,6 +309,7 @@ export const ObjectChart = (props: any) => {
const reqOpts = { headers: { accept: 'application/json' }, credentials: 'include' as const };
let objectName: string | undefined = schema.objectName;
let fieldName: string | undefined;
let datasetDef: any = null;
const gb = schema.aggregate?.groupBy as any;
if (objectName) {
fieldName = (gb && typeof gb === 'object' && !Array.isArray(gb)) ? gb.field
Expand All@@ -313,18 +319,31 @@ export const ObjectChart = (props: any) => {
const dim0 = Array.isArray(schema.dimensions) && schema.dimensions.length ? schema.dimensions[0] : undefined;
const defRes = await fetch(`/api/v1/meta/dataset/${encodeURIComponent(schema.dataset)}`, reqOpts);
const defJson = await defRes.json().catch(() => null);
const def = defJson?.item ?? defJson?.data ?? defJson;
objectName = def?.object;
const dim = (def?.dimensions || []).find((d: any) => d?.name === dim0) ?? (def?.dimensions || [])[0];
datasetDef = defJson?.item ?? defJson?.data ?? defJson;
objectName = datasetDef?.object;
const dim = (datasetDef?.dimensions || []).find((d: any) => d?.name === dim0) ?? (datasetDef?.dimensions || [])[0];
fieldName = dim?.field ?? dim0;
}
if (!objectName || !fieldName) { if (!cancelled) setFieldOptionColors(null); return; }
if (!objectName || !fieldName) { if (!cancelled) { setFieldOptionColors(null); setDimensionLabels(null); } return; }
const schemaRes = await fetch(`/api/v1/meta/object/${encodeURIComponent(objectName)}`, reqOpts);
const sj = await schemaRes.json().catch(() => null);
const objSchema = sj?.item ?? sj?.data ?? sj;
const map = buildOptionColorMap(objSchema?.fields?.[fieldName]?.options);
if (!cancelled) setFieldOptionColors(map);
} catch { if (!cancelled) setFieldOptionColors(null); }
// dataset path: build a {value → label} map for EVERY select dimension
// (the objectName path resolves labels via resolveGroupByLabels instead).
let labels: Record<string, Record<string, string>> | null = null;
if (schema.dataset && Array.isArray(schema.dimensions)) {
const acc: Record<string, Record<string, string>> = {};
for (const dimName of schema.dimensions) {
const dimDef = (datasetDef?.dimensions || []).find((d: any) => d?.name === dimName);
const f = dimDef?.field ?? dimName;
const m = buildDimensionLabelMap(objSchema?.fields?.[f]?.options);
if (m) acc[dimName] = m;
}
if (Object.keys(acc).length > 0) labels = acc;
}
if (!cancelled) { setFieldOptionColors(map); setDimensionLabels(labels); }
} catch { if (!cancelled) { setFieldOptionColors(null); setDimensionLabels(null); } }
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand DownExpand Up@@ -602,7 +621,7 @@ export const ObjectChart = (props: any) => {
// series from its dimensions/measures via the shared buildChartSeries helper —
// this pivots a second dimension into grouped series, matching DatasetWidget.
const datasetChart = schema.dataset
? buildChartSeries(finalData, schema.dimensions, schema.values)
? buildChartSeries(relabelDimensions(finalData, dimensionLabels), schema.dimensions, schema.values)
: null;

const finalSchema = datasetChart
Expand Down
Loading
Loading