diff --git a/packages/core/src/utils/__tests__/chart-series.test.ts b/packages/core/src/utils/__tests__/chart-series.test.ts index 2d3cabac3b..3c8f8e3472 100644 --- a/packages/core/src/utils/__tests__/chart-series.test.ts +++ b/packages/core/src/utils/__tests__/chart-series.test.ts @@ -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 = [ @@ -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 }, + ]); + }); +}); diff --git a/packages/core/src/utils/chart-series.ts b/packages/core/src/utils/chart-series.ts index 9813f24a6f..21497ff506 100644 --- a/packages/core/src/utils/chart-series.ts +++ b/packages/core/src/utils/chart-series.ts @@ -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> | null | undefined, + labelMaps: Record> | null | undefined, +): Array> { + 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 | 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 @@ -147,3 +196,29 @@ export function buildOptionColorMap(options: unknown): Record | } 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 | null { + if (!Array.isArray(options) || options.length === 0) return null; + const map: Record = {}; + 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; +} diff --git a/packages/plugin-charts/src/ObjectChart.tsx b/packages/plugin-charts/src/ObjectChart.tsx index de8adcb007..ac7d0069a8 100644 --- a/packages/plugin-charts/src/ObjectChart.tsx +++ b/packages/plugin-charts/src/ObjectChart.tsx @@ -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'; @@ -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 | 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> | null>(null); // Host-provided "open in list" navigation for the drill escape hatch. const { openRecordList } = useDrillNavigation(); const tt = useSafeTranslate(); @@ -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 @@ -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> | null = null; + if (schema.dataset && Array.isArray(schema.dimensions)) { + const acc: Record> = {}; + 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 @@ -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 diff --git a/packages/plugin-dashboard/src/DatasetWidget.tsx b/packages/plugin-dashboard/src/DatasetWidget.tsx index 2bd7b2d88c..af7c315672 100644 --- a/packages/plugin-dashboard/src/DatasetWidget.tsx +++ b/packages/plugin-dashboard/src/DatasetWidget.tsx @@ -31,6 +31,8 @@ import { SchemaRenderer } from '@object-ui/react'; import { buildChartSeries, buildOptionColorMap, + buildDimensionLabelMap, + relabelDimensions, findChartSeriesRow, formatMeasure, formatDimensionValue, @@ -200,6 +202,12 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: // view uses (ObjectChart). The renderer's `categoryColors` map wins over the // positional palette and falls back to it for categories without a color. const [categoryColors, setCategoryColors] = useState | null>(null); + // Per-dimension {value → label} maps. The dataset groups by a select field's + // stored value (e.g. `active`); the chart axis must read the option label + // (e.g. `合作中`). The server resolves this when it can, but an AI-built + // select whose options the analytics layer can't see comes back value-keyed, + // so we resolve it here from the object field options (see relabelDimensions). + const [dimensionLabels, setDimensionLabels] = useState> | null>(null); // Signature uses the RAW filter (stable) — the resolved one carries a // render-time `now` and would otherwise force a refetch loop. @@ -226,26 +234,35 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: // eslint-disable-next-line react-hooks/exhaustive-deps }, [signature]); - // Resolve the first dimension's select/lookup option colors (charts only; - // metric/table/pivot don't use per-category colors). The dataset query gives - // us the base `object` and the dimension→field map, so we fetch the object - // schema and build a {value|label → color} map. Best-effort: any failure - // leaves it null and the chart keeps the positional palette. + // Resolve the dimensions' select/lookup field options (charts only; + // metric/table don't use per-category colors or axis relabeling). The dataset + // query gives us the base `object` + the dimension→field map, so ONE object + // schema fetch yields both: a {value|label → color} map for the first + // dimension's per-category colors, and a {value → label} map per dimension so + // the axis/series display labels even when the server returned raw values. + // Best-effort: any failure leaves both null (positional palette + raw values). useEffect(() => { - if (isMetric || isTable) { setCategoryColors(null); return; } + if (isMetric || isTable) { setCategoryColors(null); setDimensionLabels(null); return; } const object = state.object; - const dim0 = dimensions[0]; - const field = (state.dimensionFields && state.dimensionFields[dim0]) || dim0; - if (!object || !field) { setCategoryColors(null); return; } + if (!object || dimensions.length === 0) { setCategoryColors(null); setDimensionLabels(null); return; } + const fieldOf = (dim: string) => (state.dimensionFields && state.dimensionFields[dim]) || dim; let cancelled = false; (async () => { try { const res = await fetch(`/api/v1/meta/object/${encodeURIComponent(object)}`, { headers: { accept: 'application/json' }, credentials: 'include' }); const j = await res.json().catch(() => null); const objSchema = j?.item ?? j?.data ?? j; - const map = buildOptionColorMap(objSchema?.fields?.[field]?.options); - if (!cancelled) setCategoryColors(map); - } catch { if (!cancelled) setCategoryColors(null); } + const colorMap = buildOptionColorMap(objSchema?.fields?.[fieldOf(dimensions[0])]?.options); + const labels: Record> = {}; + for (const dim of dimensions) { + const m = buildDimensionLabelMap(objSchema?.fields?.[fieldOf(dim)]?.options); + if (m) labels[dim] = m; + } + if (!cancelled) { + setCategoryColors(colorMap); + setDimensionLabels(Object.keys(labels).length > 0 ? labels : null); + } + } catch { if (!cancelled) { setCategoryColors(null); setDimensionLabels(null); } } })(); return () => { cancelled = true; }; }, [state.object, state.dimensionFields, dimensions, isMetric, isTable]); @@ -474,14 +491,19 @@ export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: // and one series per measure. Series carry the measure display label so the // legend reads "Tasks" rather than "task_count". const chartType = CHART_TYPE_MAP[widgetType] ?? 'bar'; + // Resolve select/enum dimension values → display labels before charting, so a + // value-keyed group (e.g. status=`active`) shows its label (`合作中`) on the + // axis with its count intact (cloud#667). `chartRows` stays index-aligned with + // `state.rows`/`drillRawRows`, so drill-through still maps to the raw value. + const chartRows = relabelDimensions(state.rows, dimensionLabels); // ADR-0021 (#1759): shared helper — pivots a second dimension into grouped // series so multi-dimension dataset widgets match the chart-view renderer. - const { data: chartData, xAxisKey, series } = buildChartSeries(state.rows, dimensions, values, state.fields); + const { data: chartData, xAxisKey, series } = buildChartSeries(chartRows, dimensions, values, state.fields); // Map a clicked chart segment back to its dataset row, then drill through to // the underlying records — same governed path the table/pivot rows use. const handleChartDrill = (ev: { category?: string; series?: string; value?: number }) => { - const idx = findChartSeriesRow(state.rows, dimensions, values, ev?.category, ev?.series); + const idx = findChartSeriesRow(chartRows, dimensions, values, ev?.category, ev?.series); if (idx < 0) return; // `series` is a real (second) dimension value only when pivoted; otherwise // it's the measure name — omit it from the drawer title. diff --git a/packages/plugin-dashboard/src/__tests__/DatasetWidget.relabel.test.tsx b/packages/plugin-dashboard/src/__tests__/DatasetWidget.relabel.test.tsx new file mode 100644 index 0000000000..24aeab65b0 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/DatasetWidget.relabel.test.tsx @@ -0,0 +1,117 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Regression for cloud#667: a dataset chart grouped by a `select` field whose + * stored value is English (active/lost/potential) but whose option labels are + * localized (合作中/已流失/潜在) rendered every bar at 0 — the value-keyed groups + * never lined up with the label axis. The widget must resolve value→label from + * the object field options BEFORE charting, keying by value so each count lands + * on the right (now label-displayed) category. + * + * We register a stub `chart` component to capture the schema the widget hands + * the renderer, rather than asserting on Recharts' SVG (which doesn't lay out + * in jsdom). This exercises the REAL DatasetWidget + SchemaRenderer. + */ + +import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { DatasetWidget } from '../DatasetWidget'; + +let capturedChartSchema: any = null; +beforeAll(() => { + ComponentRegistry.register('chart', (props: any) => { + capturedChartSchema = props?.schema; + return null; + }); +}); +afterEach(() => { + cleanup(); + capturedChartSchema = null; + vi.restoreAllMocks(); +}); + +const valueKeyedSource = () => ({ + // The analytics layer grouped by the stored VALUE (English enum). + queryDataset: vi.fn(async () => ({ + rows: [ + { status: 'active', count: 6 }, + { status: 'lost', count: 2 }, + { status: 'potential', count: 4 }, + ], + fields: [ + { name: 'status', type: 'select', label: '状态' }, + { name: 'count', type: 'number', label: '数量' }, + ], + object: 'tk5f_customer', + dimensionFields: { status: 'status' }, + })), +}); + +describe('DatasetWidget select-dimension relabeling (cloud#667)', () => { + it('shows the option LABEL on the axis while the value-keyed count stays attached', async () => { + const src = valueKeyedSource(); + // The object field: English values + localized labels (AI-build default). + const objectSchema = { + item: { + name: 'tk5f_customer', + fields: { + status: { + type: 'select', + options: [ + { value: 'active', label: '合作中' }, + { value: 'lost', label: '已流失' }, + { value: 'potential', label: '潜在' }, + ], + }, + }, + }, + }; + global.fetch = vi.fn(async () => ({ ok: true, json: async () => objectSchema })) as any; + + render( + , + ); + + // The object-schema fetch resolves → dimensionLabels → relabel re-render. + await waitFor(() => { + expect(capturedChartSchema).toBeTruthy(); + const byCategory = Object.fromEntries( + (capturedChartSchema.data || []).map((r: any) => [r.status, r.count]), + ); + // Every count lands on its LABEL category — the bug zeroed all of these. + expect(byCategory).toEqual({ 合作中: 6, 已流失: 2, 潜在: 4 }); + }); + // The raw English value must not leak onto the axis. + const categories = (capturedChartSchema.data || []).map((r: any) => r.status); + expect(categories).not.toContain('active'); + expect(capturedChartSchema.xAxisKey).toBe('status'); + // The object field options were fetched to build the value→label map. + expect(global.fetch).toHaveBeenCalledWith( + '/api/v1/meta/object/tk5f_customer', + expect.anything(), + ); + }); + + it('passes raw rows through unchanged (no crash) when the object schema is unavailable', async () => { + const src = valueKeyedSource(); + global.fetch = vi.fn(async () => ({ ok: false, json: async () => ({}) })) as any; + + render( + , + ); + + // First chart render carries the raw value-keyed rows; with no options the + // relabel is a no-op, so the widget never crashes and still plots a value. + await waitFor(() => { + expect(capturedChartSchema).toBeTruthy(); + expect(capturedChartSchema.data?.[0]).toMatchObject({ status: 'active', count: 6 }); + }); + }); +});