From 18c9ce06bf7dd322d9dbc083e70cd1030fd952d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 18:30:41 +0000 Subject: [PATCH 1/3] wip: metric i18n channel --- .../inspectors/DashboardWidgetInspector.tsx | 11 +- packages/core/src/utils/dashboard-filters.ts | 62 +++++- .../src/DashboardFilterBar.tsx | 57 +++++- .../src/DashboardRenderer.tsx | 62 ++++-- packages/plugin-dashboard/src/MetricCard.tsx | 24 ++- .../plugin-dashboard/src/MetricWidget.tsx | 43 ++-- .../DashboardFilterBar.i18nLabel.test.tsx | 180 +++++++++++++++++ .../DashboardRenderer.metricI18n.test.tsx | 190 ++++++++++++++++++ .../__tests__/MetricWidget.i18nLabel.test.tsx | 84 ++++++++ packages/types/src/complex.ts | 54 +++-- 10 files changed, 678 insertions(+), 89 deletions(-) create mode 100644 packages/plugin-dashboard/src/__tests__/DashboardFilterBar.i18nLabel.test.tsx create mode 100644 packages/plugin-dashboard/src/__tests__/DashboardRenderer.metricI18n.test.tsx create mode 100644 packages/plugin-dashboard/src/__tests__/MetricWidget.i18nLabel.test.tsx diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx index 64b6517b4d..897c21fc75 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx @@ -366,7 +366,14 @@ export function DashboardWidgetInspector({
setBinding(v ? v : undefined)} diff --git a/packages/core/src/utils/dashboard-filters.ts b/packages/core/src/utils/dashboard-filters.ts index 3dfe4bbf24..70c2d0baa1 100644 --- a/packages/core/src/utils/dashboard-filters.ts +++ b/packages/core/src/utils/dashboard-filters.ts @@ -20,7 +20,7 @@ * are unit-testable in isolation from React and the data layer. */ -import type { DashboardComponentSchema, DashboardWidgetSchema, PageVariable } from '@object-ui/types'; +import type { DashboardComponentSchema, DashboardWidgetSchema, I18nLabel, PageVariable } from '@object-ui/types'; import { liftLegacyGlobalFilterDefault } from '@object-ui/types'; import { DATE_RANGE_PRESETS, type DateRangePreset } from '@objectstack/spec/ui'; import { resolveDateMacros } from './date-macros.js'; @@ -36,15 +36,35 @@ export interface DashboardFilterDef { name: string; /** Default target field when a widget declares no explicit binding. */ field: string; - label?: string; + /** + * Display label, in @objectstack/spec's `I18nLabel` vocabulary — a plain + * string, or an inline per-locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`). + * + * Widened from `string` under the objectstack#5428 ruling of 2026-08-06 + * (option A): `GlobalFilterSchema.label` has been `I18nLabel` on the spec + * side since 17.0.0-rc.6, so declaring it `string` here made this module + * NARROWER than the contract it implements — which is exactly why the filter + * bar's map reads were invisible to `tsc` (objectui#4163). + * + * This module is locale-free BY DESIGN (`@object-ui/core` is logic-only), so + * it carries the authored vocabulary through unresolved and the RENDER side + * collapses it to the active language. Resolving here would need a locale + * this layer has no business knowing. + */ + label?: string | I18nLabel; type: 'text' | 'select' | 'date' | 'number' | 'lookup' | 'dateRange'; /** * Static options, NORMALIZED to `{ value, label }` pairs by * `resolveDashboardFilterDefs` — authors may write either the * @objectstack/spec object form (`{ value, label }`) or the bare-string * shorthand; consumers always see the object form. + * + * The PAIR SHAPE is normalized; the label's own vocabulary is not. `label` + * is `I18nLabel` in `GlobalFilterSchema.options[]` too, and it reaches the + * renderer as authored — see `normalizeFilterOptions` for why collapsing it + * here was data loss rather than normalization. */ - options?: Array<{ value: string; label: string }>; + options?: Array<{ value: string; label: string | I18nLabel }>; optionsFrom?: { object: string; valueField: string; @@ -288,25 +308,47 @@ function normalizeDateDefault(type: DashboardFilterDef['type'], defaultValue: un /** * Normalize a filter's static `options` declaration to `{ value, label }` * pairs. The @objectstack/spec `GlobalFilterSchema.options` form is - * `{ value, label }` objects (label possibly an i18n record); the bare-string - * shorthand (`options: ['EMEA', …]`) is also accepted. Rendering an - * un-normalized object child crashes React — this is the single place both - * shapes converge. + * `{ value, label }` objects; the bare-string shorthand (`options: ['EMEA', …]`) + * is also accepted. Rendering an un-normalized option crashes React — this is + * the single place both shapes converge. + * + * ## What is normalized, and what is deliberately NOT (objectui#4032 / #4163) + * + * The PAIR SHAPE is normalized (`value` stringified, a bare string lifted to a + * pair). The LABEL's authoring vocabulary is carried through untouched, because + * `label` is `I18nLabel` — a string OR an inline per-locale map. + * + * This line used to read: + * + * ```ts + * label: typeof label === 'string' && label ? label : String(value), + * ``` + * + * which looks like defensive normalization and is data loss. An option authored + * `{ value: 'domestic', label: { en: 'Domestic', 'zh-CN': '国内' } }` normalized + * to `label: 'domestic'` — the raw STORED VALUE — so the control lost the + * authored text in *every* locale, English included. Nothing downstream could + * recover it: by the time a renderer saw the def, the map was gone. + * + * A map is therefore preserved and the render side resolves it against the + * active language. Anything that is neither a string nor an object is not a + * label in any vocabulary the spec admits, and still falls back to the value. */ function normalizeFilterOptions( options: unknown, -): Array<{ value: string; label: string }> | undefined { +): Array<{ value: string; label: string | I18nLabel }> | undefined { if (!Array.isArray(options) || options.length === 0) return undefined; - const normalized: Array<{ value: string; label: string }> = []; + const normalized: Array<{ value: string; label: string | I18nLabel }> = []; for (const o of options) { if (o === null || o === undefined) continue; if (typeof o === 'object') { const value = (o as any).value; if (value === undefined || value === null) continue; const label = (o as any).label; + const isMap = label !== null && typeof label === 'object' && !Array.isArray(label); normalized.push({ value: String(value), - label: typeof label === 'string' && label ? label : String(value), + label: (typeof label === 'string' && label) || isMap ? label : String(value), }); } else { normalized.push({ value: String(o), label: String(o) }); diff --git a/packages/plugin-dashboard/src/DashboardFilterBar.tsx b/packages/plugin-dashboard/src/DashboardFilterBar.tsx index c7a86ed15e..bde7619d50 100644 --- a/packages/plugin-dashboard/src/DashboardFilterBar.tsx +++ b/packages/plugin-dashboard/src/DashboardFilterBar.tsx @@ -32,13 +32,42 @@ import { SelectValue, } from '@object-ui/components'; import { CalendarIcon, RotateCcw } from 'lucide-react'; -import { useSafeTranslate } from '@object-ui/i18n'; +import { useSafeTranslate, useObjectTranslation, pickLocalized } from '@object-ui/i18n'; import { DATE_RANGE_PRESETS, type DashboardFilterDef, type DateRangeValue, } from '@object-ui/core'; +/** + * The filter's display name for the active UI language (objectui#4032, merged + * scope from the #4163 part-1 audit). + * + * `GlobalFilterSchema.label` is the spec's `I18nLabel`, so an author may write + * an inline per-locale map. Every read site below used to be `def.label || + * def.name`, which is wrong TWICE over: + * + * 1. a map reached a text node / `aria-label` / `placeholder` and stringified + * to `[object Object]` — in the Select's case inside a template literal, + * rendering `[object Object]: All`; + * 2. an object is ALWAYS TRUTHY, so `||` never fell through to `def.name` — + * not even for `{}`, or a map with no entry for any locale. The same + * truthiness fact #4163 pinned on `DashboardGridLayout`'s header gate. + * + * Resolving FIRST and testing the resolved string fixes both at once: there is + * one call, it yields a string, and the `||` fallback below is reached exactly + * when that string is empty. + * + * Returns `''` rather than applying a fallback itself, because the three + * controls do not share one: the built-in `dateRange` falls back to a + * TRANSLATED "Date range", the others to the raw `def.name`. Folding those + * together here would have made an unlabelled date filter read `dateRange`. + */ +function useFilterLabel(def: DashboardFilterDef): string { + const { language } = useObjectTranslation(); + return pickLocalized(def.label, language); +} + /** Sentinel for the Select's clear item (Radix Select forbids empty values). */ const ALL_VALUE = '__all__'; /** Sentinel for the date-range Select's "Custom…" item. */ @@ -70,6 +99,7 @@ function toIsoDate(d: Date): string { function DateRangeFilter({ def, value, onChange }: { def: DashboardFilterDef; value: DateRangeValue | undefined; onChange: (v: DateRangeValue | undefined) => void }) { const tt = useSafeTranslate(); + const label = useFilterLabel(def); const [customOpen, setCustomOpen] = useState(false); const allowCustom = def.allowCustomRange !== false; const presetLabel = (p: string) => tt(`dashboard.filters.range.${p}`, p.replace(/_/g, ' ')); @@ -86,7 +116,7 @@ function DateRangeFilter({ def, value, onChange }: { def: DashboardFilterDef; va else onChange({ preset: v }); }} > - + {rangeLabel(value, presetLabel) ?? tt('dashboard.filters.allTime', 'All time')} @@ -133,6 +163,8 @@ function DateRangeFilter({ def, value, onChange }: { def: DashboardFilterDef; va function SelectFilter({ def, value, onChange, dataSource }: { def: DashboardFilterDef; value: string | undefined; onChange: (v: string | undefined) => void; dataSource?: any }) { const tt = useSafeTranslate(); + const { language } = useObjectTranslation(); + const resolvedLabel = useFilterLabel(def); const [dynamicOptions, setDynamicOptions] = useState | null>(null); // Dynamic options, server-side first (#2578 item 5): when the data source @@ -217,13 +249,17 @@ function SelectFilter({ def, value, onChange, dataSource }: { def: DashboardFilt }, [from?.object, from?.valueField, from?.labelField, dataSource]); const options = useMemo(() => { - // def.options is already normalized to { value, label } pairs by - // resolveDashboardFilterDefs (spec object form and string shorthand alike). - if (def.options?.length) return def.options; - return dynamicOptions ?? []; - }, [def.options, dynamicOptions]); + // `def.options` is already normalized to `{ value, label }` PAIRS by + // `resolveDashboardFilterDefs`; the label's own vocabulary is not, and + // deliberately so (`@object-ui/core` is locale-free — see + // `normalizeFilterOptions`). Collapsing each label to the active language + // is this layer's job, and it happens once here so both the dropdown and + // the trigger's selected-value text read the same resolved string. + const authored = def.options?.length ? def.options : (dynamicOptions ?? []); + return authored.map((o) => ({ value: o.value, label: pickLocalized(o.label, language) || o.value })); + }, [def.options, dynamicOptions, language]); - const label = def.label || def.name; + const label = resolvedLabel || def.name; const selectedLabel = value ? options.find((o) => o.value === String(value))?.label ?? String(value) : undefined; @@ -248,6 +284,7 @@ function SelectFilter({ def, value, onChange, dataSource }: { def: DashboardFilt } function TextFilter({ def, value, onChange }: { def: DashboardFilterDef; value: any; onChange: (v: any) => void }) { + const label = useFilterLabel(def) || def.name; const [draft, setDraft] = useState(value == null ? '' : String(value)); useEffect(() => { setDraft(value == null ? '' : String(value)); }, [value]); const commit = () => { @@ -259,12 +296,12 @@ function TextFilter({ def, value, onChange }: { def: DashboardFilterDef; value: setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => { if (e.key === 'Enter') commit(); }} - aria-label={def.label || def.name} + aria-label={label} data-testid={`dashboard-filter-${def.name}`} /> ); diff --git a/packages/plugin-dashboard/src/DashboardRenderer.tsx b/packages/plugin-dashboard/src/DashboardRenderer.tsx index 0618b1671e..78f9c9283a 100644 --- a/packages/plugin-dashboard/src/DashboardRenderer.tsx +++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx @@ -8,7 +8,7 @@ import type { DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui/types'; import { SchemaRenderer, useActionEngine, useObjectLabel, PageVariablesProvider, usePageVariables } from '@object-ui/react'; -import { useObjectTranslation } from '@object-ui/i18n'; +import { useObjectTranslation, pickLocalized } from '@object-ui/i18n'; import type { ActionDef, ActionResult, ActionContext, ModalHandler } from '@object-ui/core'; import { resolveDashboardFilterDefs, @@ -88,13 +88,6 @@ function resolveLucideIcon(name?: string): React.ElementType | null { return getLazyIcon(name); } -/** Resolve an I18nLabel (string or {key, defaultValue}) to a plain string. */ -function resolveLabel(label: string | { key?: string; defaultValue?: string } | undefined): string | undefined { - if (label === undefined || label === null) return undefined; - if (typeof label === 'string') return label; - return label.defaultValue || label.key; -} - // Color palette for charts const CHART_COLORS = [ 'hsl(var(--chart-1))', @@ -253,7 +246,30 @@ const DashboardRendererInner = forwardRef { + if (label === undefined || label === null) return undefined; + return pickLocalized(label, language) || undefined; + }, + [language], + ); /** * Resolve a chart series label. When the y-field defaults to a synthetic * key like 'value' (used by count aggregations that have no real field), @@ -291,6 +307,13 @@ const DashboardRendererInner = forwardRef { @@ -298,7 +321,7 @@ const DashboardRendererInner = forwardRef = ({ }) => { // Resolve icon via lazy resolver — each icon ships as its own micro-chunk const IconComponent = icon ? getLazyIcon(icon) : null; + // Label text follows the active UI language (not the tenant's number locale). + const { language } = useObjectTranslation(); return ( - {resolveLabel(title)} + {pickLocalized(title, language)} {IconComponent && ( // eslint-disable-next-line react-hooks/static-components -- getLazyIcon returns a module-cached stable component per name, not one created during render @@ -91,7 +93,7 @@ export const MetricCard: React.FC = ({ {trendValue} )} - {resolveLabel(description)} + {pickLocalized(description, language)}

)} diff --git a/packages/plugin-dashboard/src/MetricWidget.tsx b/packages/plugin-dashboard/src/MetricWidget.tsx index 92a65408da..7940981783 100644 --- a/packages/plugin-dashboard/src/MetricWidget.tsx +++ b/packages/plugin-dashboard/src/MetricWidget.tsx @@ -4,9 +4,12 @@ import { cn } from '@object-ui/components'; import { createSafeTranslation, useDisplayLocale, + useObjectTranslation, + pickLocalized, formatDisplayNumber, type DisplayNumberFormatOptions, } from '@object-ui/i18n'; +import type { I18nLabel } from '@object-ui/types'; import { ArrowDownIcon, ArrowUpIcon, MinusIcon, AlertCircle, Loader2 } from 'lucide-react'; import { VARIANT_ICON_CLASSES, VARIANT_TEXT_CLASSES, type MetricColorVariant } from './colorVariants'; @@ -136,13 +139,6 @@ function formatMetricValue( }); } -/** Resolve an I18nLabel (string or {key, defaultValue}) to a plain string. */ -function resolveLabel(label: string | { key?: string; defaultValue?: string } | undefined): string | undefined { - if (label === undefined || label === null) return undefined; - if (typeof label === 'string') return label; - return label.defaultValue || label.key; -} - /** * The variant vocabulary and its two class tables now live in * `./colorVariants` — the dataset-bound KPI (`DatasetWidget`, objectui#3359) @@ -153,16 +149,27 @@ function resolveLabel(label: string | { key?: string; defaultValue?: string } | export type { MetricColorVariant }; export interface MetricWidgetProps { - label: string | { key?: string; defaultValue?: string }; + /** + * The KPI's heading, in @objectstack/spec's `I18nLabel` vocabulary — a plain + * string or an inline per-locale map (`{ en: 'Revenue', 'zh-CN': '收入' }`). + * + * Was `string | { key?, defaultValue? }` — the key-reference form + * objectstack#5055 RETIRED. It is not accepted here any more because the spec + * rejects it at authoring time, and because reading it was never the point: + * the private resolver behind it ended `defaultValue || key` and never + * consulted a bundle, so the escape hatch was declared and inert + * (objectui#4032). + */ + label: string | I18nLabel; value: string | number; trend?: { value: number; - label?: string | { key?: string; defaultValue?: string }; + label?: string | I18nLabel; direction?: 'up' | 'down' | 'neutral'; }; icon?: React.ReactNode | string; className?: string; - description?: string | { key?: string; defaultValue?: string }; + description?: string | I18nLabel; /** When true, the widget is in a loading state (fetching data from server). */ loading?: boolean; /** Error message from a failed data fetch. When set, the widget shows an error state. */ @@ -207,14 +214,22 @@ export const MetricWidget = ({ }: MetricWidgetProps) => { const iconClasses = VARIANT_ICON_CLASSES[colorVariant] || VARIANT_ICON_CLASSES.default; const { t: tTrend } = useTrendT(); + // Two locale channels, deliberately distinct. `useDisplayLocale` is the + // NUMBER locale (objectui#4033 — tenant regional default outranks UI + // language); `language` is the UI language, which is what label text follows. + // Keep them separate: swapping either for the other silently changes the + // other surface's behaviour. const locale = useDisplayLocale(); + const { language } = useObjectTranslation(); + + const resolvedLabel = useMemo(() => pickLocalized(label, language) || undefined, [label, language]); const localizedTrendLabel = useMemo(() => { - const raw = resolveLabel(description) || resolveLabel(trend?.label); + const raw = pickLocalized(description, language) || pickLocalized(trend?.label, language) || undefined; if (!raw) return raw; const key = trendLabelKey(raw); return key ? tTrend(key) : raw; - }, [description, trend?.label, tTrend]); + }, [description, trend?.label, tTrend, language]); const displayValue = useMemo(() => { const formatted = typeof value === 'number' || (typeof value === 'string' && value.trim() !== '' && isFinite(Number(value))) @@ -260,7 +275,7 @@ export const MetricWidget = ({ ) : ( <>
{displayValue}
-
{resolveLabel(label)}
+
{resolvedLabel}
)}
@@ -287,7 +302,7 @@ export const MetricWidget = ({ > - {resolveLabel(label)} + {resolvedLabel} {resolvedIcon && (
= {}) { + return render( + + + , + ); +} + +describe('DashboardFilterBar — inline per-locale filter labels (#4032 / #4163)', () => { + it('(c) resolves a map-valued filter label in the trigger text and the aria-label', () => { + const { container } = renderIn('zh', [ + { + name: 'owner', + field: 'owner', + type: 'select', + label: { en: 'Owner', 'zh-CN': '负责人' }, + options: [{ value: 'alice', label: 'Alice' }], + } as unknown as DashboardFilterDef, + ]); + + expect(container.innerHTML).not.toContain('[object Object]'); + const trigger = screen.getByTestId('dashboard-filter-owner'); + expect(trigger.getAttribute('aria-label')).toBe('负责人'); + expect(trigger.textContent).toContain('负责人'); + }); + + it('(c2) a label map that resolves to nothing falls through to the filter name', () => { + // The truthiness half — an object is always truthy, so `def.label || + // def.name` never reached `def.name` for ANY object, empty or not. + const { container } = renderIn('zh', [ + { name: 'region', field: 'region', type: 'select', label: {}, options: [] } as unknown as DashboardFilterDef, + ]); + + expect(container.innerHTML).not.toContain('[object Object]'); + expect(screen.getByTestId('dashboard-filter-region').getAttribute('aria-label')).toBe('region'); + }); + + it('(c3) resolves a map label on the text filter — placeholder AND aria-label', () => { + renderIn('zh', [ + { name: 'note', field: 'note', type: 'text', label: { en: 'Note', 'zh-CN': '备注' } } as unknown as DashboardFilterDef, + ]); + + const input = screen.getByTestId('dashboard-filter-note'); + expect(input.getAttribute('placeholder')).toBe('备注'); + expect(input.getAttribute('aria-label')).toBe('备注'); + }); + + it('(c4) resolves a map label on the date-range filter aria-label', () => { + renderIn('zh', [ + { name: 'dateRange', field: 'created_at', type: 'dateRange', label: { en: 'Period', 'zh-CN': '期间' } } as unknown as DashboardFilterDef, + ]); + + expect(screen.getByLabelText('期间')).toBeTruthy(); + }); + + it('(d) keeps a map-valued OPTION label and resolves it per locale', () => { + // Through the real normalizer, because the discard happens there — a def + // hand-written in the object form would not exercise the reported defect. + const defs = resolveDashboardFilterDefs({ + globalFilters: [ + { + name: 'channel', + field: 'sales_channel', + type: 'select', + options: [{ value: 'domestic', label: { en: 'Domestic', 'zh-CN': '国内' } }], + }, + ], + } as never); + + renderIn('zh', defs, { channel: 'domestic' }); + expect(screen.getByTestId('dashboard-filter-channel').textContent).toContain('国内'); + }); + + it('(d2) an `en` UI keeps the AUTHORED english option text, not the stored value', () => { + // The half that makes this data loss rather than a translation gap: before + // the change this rendered `domestic`, the raw stored value, in en too. + const defs = resolveDashboardFilterDefs({ + globalFilters: [ + { + name: 'channel', + field: 'sales_channel', + type: 'select', + options: [{ value: 'domestic', label: { en: 'Domestic', 'zh-CN': '国内' } }], + }, + ], + } as never); + + renderIn('en', defs, { channel: 'domestic' }); + expect(screen.getByTestId('dashboard-filter-channel').textContent).toContain('Domestic'); + }); + + it('(e0) an UNLABELLED date filter keeps its translated fallback, not the reserved name', () => { + // Regression pin for the shape of the fix, not for the reported defect. + // The three controls do NOT share a fallback — `dateRange` falls back to a + // translated "Date range", the others to `def.name` — so a resolver that + // helpfully applied `|| def.name` itself would have made this control read + // the reserved string `dateRange`. Caught while writing the helper; pinned + // so the next simplification cannot re-collapse them. + renderIn('en', [ + { name: 'dateRange', field: 'created_at', type: 'dateRange' } as unknown as DashboardFilterDef, + ]); + + expect(screen.getByLabelText('Date range')).toBeTruthy(); + expect(screen.queryByLabelText('dateRange')).toBeNull(); + }); + + it('(e) leaves plain-string labels and options exactly as authored', () => { + // Non-vacuity + acceptance boundary for all of the above. + const defs = resolveDashboardFilterDefs({ + globalFilters: [ + { + name: 'stage', + field: 'stage', + type: 'select', + label: 'Stage', + options: [{ value: 'won', label: 'Won' }, 'lost'], + }, + ], + } as never); + + expect(defs[0].options).toEqual([ + { value: 'won', label: 'Won' }, + { value: 'lost', label: 'lost' }, + ]); + + renderIn('zh', defs, { stage: 'won' }); + const trigger = screen.getByTestId('dashboard-filter-stage'); + expect(trigger.getAttribute('aria-label')).toBe('Stage'); + expect(trigger.textContent).toContain('Won'); + }); +}); diff --git a/packages/plugin-dashboard/src/__tests__/DashboardRenderer.metricI18n.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardRenderer.metricI18n.test.tsx new file mode 100644 index 0000000000..490213b82f --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/DashboardRenderer.metricI18n.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#4032 (source thread objectstack#5428) — a `type: 'metric'` KPI card + * drops the translated widget title while every OTHER widget type on the same + * dashboard renders it. + * + * TWO INDEPENDENT CHANNELS reach a widget title, and the metric path was + * disconnected from both: + * + * 1. **The convention-key channel** — `{ns}.dashboards.{dash}.widgets.{id}.title`, + * resolved by `tWidgetTitle`. `DashboardRenderer` computes `resolvedTitle` + * for every widget, but the self-contained metric branch renders no Card + * header, so the value was computed and thrown away; the metric dispatch + * built its own label from the raw authored `widget.title` instead. + * 2. **The inline per-locale map** — `{ en: 'Revenue', 'zh-CN': '收入' }`, which + * `@objectstack/spec` 17.0.0-rc.6 admits on `DashboardWidget.title` + * (`I18nLabel`). The private `resolveLabel` copy this file's subject used + * read the RETIRED `{ key, defaultValue }` form (objectstack#5055) and + * answered `undefined` for a map — see the direction note below. + * + * DIRECTIONS, written before the reverse verification was run: + * + * - **(a) bundle-translated title/description → RED before the change.** The + * metric branch never consulted `tWidgetTitle`, so the card rendered the + * authored English in a `zh` UI. + * - **(b) inline map title → RED before the change, and NOT as `[object + * Object]`.** This is the prediction worth writing down, because it is not + * the failure the sibling surface had. `resolveLabel` ended + * `return label.defaultValue || label.key`, and a locale map has neither — + * so it returned `undefined`, and the caller's `|| widgetType` fallback took + * over. The card therefore rendered the literal widget TYPE, the string + * `"metric"`. A map that stringifies is at least visible; this one silently + * substitutes a plausible-looking word. + * - **(f) controls → GREEN on both sides.** A plain-string title must stay + * byte-identical, and a non-metric widget's title path is untouched. These + * are the acceptance boundary, not decoration: the fix must not move a + * dashboard that has no translations at all. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import type { DashboardComponentSchema } from '@object-ui/types'; +// The dashboard renders each widget through `SchemaRenderer`, which resolves +// `metric` / `chart` from the registry — populated as a side effect of the +// package barrel. Imported at MODULE scope (never in a hook) per AGENTS.md's +// flaky-test rule: the cost lands in the import phase, unbounded by any +// test/hook timeout. +import { DashboardRenderer } from '../index'; + +afterEach(cleanup); + +/** + * The app bundle. `crm` is discovered as an app namespace because it carries a + * `dashboards` sub-key — the same discovery `useObjectLabel` performs for every + * other convention lookup. + */ +const ZH_BUNDLE = { + zh: { + crm: { + dashboards: { + sales: { + widgets: { + revenue: { title: '总收入', description: '本季度已赢单' }, + pipeline: { title: '销售漏斗' }, + }, + }, + }, + }, + }, +}; + +function dashboard(widget: Record): DashboardComponentSchema { + return { + type: 'dashboard', + name: 'sales', + widgets: [widget], + } as unknown as DashboardComponentSchema; +} + +/** The KPI card's heading row — the slot every assertion below is about. */ +function cardHeading(): HTMLElement { + const el = document.querySelector('.tracking-tight.text-sm.font-medium'); + if (!el) throw new Error('metric card heading not rendered'); + return el as HTMLElement; +} + +function renderIn(language: string, schema: DashboardComponentSchema) { + return render( + + + , + ); +} + +describe('DashboardRenderer — KPI cards translate from the widget convention keys (#4032)', () => { + it('(a) renders the bundle title on a self-contained metric card', () => { + renderIn('zh', dashboard({ + id: 'revenue', + type: 'metric', + title: 'Total Revenue', + options: { value: 1930000 }, + })); + + expect(screen.getByText('总收入')).toBeTruthy(); + // The authored English must not survive beside the translation. + expect(screen.queryByText('Total Revenue')).toBeNull(); + }); + + it('(b) resolves an inline per-locale title map, and never leaks the widget type', () => { + renderIn('zh', dashboard({ + id: 'unkeyed', + type: 'metric', + title: { en: 'Total Revenue', 'zh-CN': '总收入' }, + options: { value: 1930000 }, + })); + + expect(screen.getByText('总收入')).toBeTruthy(); + // The pre-fix failure mode: `resolveLabel` answered `undefined` for a map, + // so `|| widgetType` rendered the literal string "metric". + expect(screen.queryByText('metric')).toBeNull(); + // And the sibling surface's failure mode, which this path never had. + // Asserted on the HEADING, not on `container.innerHTML`: the card also + // carries an unrelated `schema="[object Object]"` DOM attribute, present on + // this path before and after this change and on plain-string titles too — + // `SchemaRenderer` hands the widget schema down and `MetricWidget` spreads + // `...props` onto the `Card`. Filed separately rather than widened into + // this pin, which would have made the assertion pass for the wrong reason. + expect(cardHeading().innerHTML).not.toContain('[object Object]'); + }); + + it('(b2) resolves the inline map for `en` too — the authored en text, not the raw map', () => { + renderIn('en', dashboard({ + id: 'unkeyed', + type: 'metric', + title: { en: 'Total Revenue', 'zh-CN': '总收入' }, + options: { value: 1930000 }, + })); + + expect(screen.getByText('Total Revenue')).toBeTruthy(); + expect(cardHeading().innerHTML).not.toContain('[object Object]'); + }); + + it('(b3) prefers the bundle over the inline map when both exist', () => { + // Precedence is the same one `useObjectLabel` applies everywhere: the + // authored value is collapsed to the active language FIRST and then offered + // to the bundle as its fallback, so a bundle entry always wins. + renderIn('zh', dashboard({ + id: 'revenue', + type: 'metric', + title: { en: 'Total Revenue', 'zh-CN': '收入合计' }, + options: { value: 1930000 }, + })); + + expect(screen.getByText('总收入')).toBeTruthy(); + expect(screen.queryByText('收入合计')).toBeNull(); + }); + + it('(f) leaves a plain-string title on a metric exactly as authored when nothing translates it', () => { + // Non-vacuity + the acceptance boundary: an app with no bundle entry for + // this widget keeps the exact label it renders today. + renderIn('zh', dashboard({ + id: 'untranslated', + type: 'metric', + title: 'Win Rate', + options: { value: '42%' }, + })); + + expect(screen.getByText('Win Rate')).toBeTruthy(); + }); + + it('(f2) leaves the NON-metric title path unchanged — it already read the bundle', () => { + renderIn('zh', dashboard({ + id: 'pipeline', + type: 'bar', + title: 'Pipeline', + options: { data: [{ name: 'a', value: 1 }] }, + })); + + expect(screen.getByText('销售漏斗')).toBeTruthy(); + }); +}); diff --git a/packages/plugin-dashboard/src/__tests__/MetricWidget.i18nLabel.test.tsx b/packages/plugin-dashboard/src/__tests__/MetricWidget.i18nLabel.test.tsx new file mode 100644 index 0000000000..bb3066d49a --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/MetricWidget.i18nLabel.test.tsx @@ -0,0 +1,84 @@ +/** + * 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#4032 — the OTHER two of the three private `resolveLabel` copies. + * + * `MetricWidget` and `MetricCard` are public SDUI blocks: a schema may address + * them directly (`type: 'metric'` / `'metric-card'`) without going through + * `DashboardRenderer`, so each has to resolve its own labels. Both carried the + * same private resolver ending `return label.defaultValue || label.key`, and + * both therefore rendered NOTHING for the inline per-locale map that + * `@objectstack/spec` actually admits — the map has neither key. + * + * DIRECTIONS: every map case below is RED before the change (empty heading), + * every plain-string case is GREEN on both sides. + * + * The `bare` variant is pinned alongside `card` because it is a SECOND read of + * the same prop in the same file — the kind of second site a per-call fix + * misses. + */ + +import * as React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { MetricWidget } from '../MetricWidget'; +import { MetricCard } from '../MetricCard'; + +afterEach(cleanup); + +function renderIn(language: string, ui: React.ReactElement) { + return render( + + {ui} + , + ); +} + +describe('MetricWidget / MetricCard — inline per-locale labels (#4032)', () => { + it('MetricWidget resolves a map `label` to the active locale', () => { + renderIn('zh', ); + expect(screen.getByText('收入')).toBeTruthy(); + }); + + it('MetricWidget resolves a map `label` in the `bare` variant too', () => { + renderIn('zh', ); + expect(screen.getByText('收入')).toBeTruthy(); + }); + + it('MetricWidget resolves a map `description` (the sub-caption slot)', () => { + renderIn('zh', ); + expect(screen.getByText('本月')).toBeTruthy(); + }); + + it('MetricWidget keeps the trend-phrase translation working on a plain string', () => { + // #4333's neighbour behaviour: a recognised English trend phrase still maps + // to its canonical key and translates. The map support must not disturb it. + renderIn('en', ); + expect(screen.getByText('vs last quarter')).toBeTruthy(); + }); + + it('MetricCard resolves map `title` and `description`', () => { + renderIn('zh', ( + + )); + expect(screen.getByText('收入')).toBeTruthy(); + expect(screen.getByText('本月')).toBeTruthy(); + }); + + it('leaves plain-string labels exactly as authored', () => { + // Non-vacuity for all of the above. + renderIn('zh', ); + expect(screen.getByText('Revenue')).toBeTruthy(); + }); +}); diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index fa0af2d396..d8b0731870 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -15,7 +15,10 @@ * @packageDocumentation */ -import type { DashboardWidget as SpecDashboardWidget } from '@objectstack/spec/ui'; +import type { + DashboardWidget as SpecDashboardWidget, + GlobalFilter as SpecGlobalFilter, +} from '@objectstack/spec/ui'; import type { BaseSchema, SchemaNode } from './base'; /** @@ -753,35 +756,26 @@ export interface DashboardComponentSchema extends BaseSchema { /** * Global filter configurations. * Applied across all dashboard widgets. - * Aligned with @objectstack/spec GlobalFilterSchema. - */ - globalFilters?: Array<{ - /** - * Stable filter name used as the dashboard-variable key and as the key - * widgets reference in `filterBindings`. Defaults to `field`. - * Aligned with @objectstack/spec GlobalFilterSchema.name - * (framework#2501). - */ - name?: string; - field: string; - label?: string; - type?: 'text' | 'select' | 'date' | 'number' | 'lookup'; - /** - * Static options. The @objectstack/spec form is `{ value, label }` - * objects; the bare-string shorthand is also accepted (normalized by - * the runtime). - */ - options?: Array; - optionsFrom?: { - object: string; - valueField: string; - labelField?: string; - filter?: any; - }; - defaultValue?: any; - scope?: string; - targetWidgets?: string[]; - }>; + * + * BOUND to `@objectstack/spec`'s `GlobalFilter` rather than restated + * (objectui#4032, merged scope from the #4163 part-1 audit). It used to be a + * hand-written inline object literal that happened to carry the same nine + * keys, and the copy drifted in the one direction that costs the most: + * `label` was declared `string` here long after the spec widened it (and its + * `options[].label`) to `I18nLabel`. Because the restatement was NARROWER + * than the contract, an authored per-locale map was a legal document that + * objectui's own types said could not exist — so every read site that + * stringified one was invisible to `tsc`, which is precisely how the filter + * bar shipped `[object Object]: All` and how `normalizeFilterOptions` shipped + * a coercion that discarded map labels in every locale. + * + * `DashboardWidgetSchema` above already takes this route (`extends + * Omit, …>`), which is why the widget half of + * the same widening WAS compile-visible and got repaired in #4208. Binding is + * preferred over restating for exactly that asymmetry: the key set and the + * value vocabularies now track the protocol instead of a snapshot of it. + */ + globalFilters?: SpecGlobalFilter[]; /** * Date range filter configuration. * Aligned with @objectstack/spec DashboardSchema.dateRange. From 5ff478e15fa86f5dc111f8cfc68d853bb6427509 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 18:58:21 +0000 Subject: [PATCH 2/3] wip: binding pin --- ...hboard-global-filters-spec-binding.test.ts | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 packages/types/src/__tests__/dashboard-global-filters-spec-binding.test.ts diff --git a/packages/types/src/__tests__/dashboard-global-filters-spec-binding.test.ts b/packages/types/src/__tests__/dashboard-global-filters-spec-binding.test.ts new file mode 100644 index 0000000000..63b02d2381 --- /dev/null +++ b/packages/types/src/__tests__/dashboard-global-filters-spec-binding.test.ts @@ -0,0 +1,105 @@ +/** + * 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#4032 — `DashboardComponentSchema.globalFilters` is BOUND to + * `@objectstack/spec`'s `GlobalFilter`, not restated. + * + * ## Why this file exists at all + * + * This is the one part of #4032 that **no runtime test can falsify**. The + * restatement was a hand-written object literal carrying the same nine keys, so + * it type-checked, rendered, and shipped — it was simply NARROWER than the + * contract in one place (`label`, and `options[].label`, declared `string` after + * the spec widened both to `I18nLabel`). A type that is too narrow does not + * fail; it makes the read sites that would have failed invisible to `tsc` + * instead. That invisibility is the whole reason the filter bar shipped + * `[object Object]: All` and `normalizeFilterOptions` shipped a coercion that + * discarded map labels in every locale, English included. + * + * So the pins below are compile-time by necessity: reverting `globalFilters` to + * the old inline literal turns THIS FILE red (`tsc -p tsconfig.test.json`, run + * by `type-check`) and leaves every runtime suite green — which is precisely the + * failure signature the drift had, reproduced deliberately. + * + * The sibling half of the same widening WAS compile-visible and got repaired in + * #4208, for one structural reason: `DashboardWidgetSchema` takes its keys from + * the spec through `extends`. These pins hold `globalFilters` to that same + * route. + */ + +import { describe, it, expect } from 'vitest'; +import type { DashboardComponentSchema } from '../complex'; + +type GlobalFilterOf = NonNullable[number]; + +type Assert = T; +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +/** True when `V` is assignable to the type of `K` on a global filter. */ +type Accepts< K extends keyof GlobalFilterOf, V > = V extends GlobalFilterOf[K] ? true : false; + +describe('DashboardComponentSchema.globalFilters — bound to the spec, not restated (#4032)', () => { + it('accepts an inline per-locale map on the filter label', () => { + // The exact shape the restatement made unrepresentable. `Record< string, + // string >` is `I18nLabel`'s inline-map half. + type _MapLabel = Assert< Accepts< 'label', { en: string; 'zh-CN': string } > >; + // …without having lost the plain-string half. + type _StringLabel = Assert< Accepts< 'label', string > >; + + const filter: GlobalFilterOf = { + field: 'owner', + name: 'owner', + label: { en: 'Owner', 'zh-CN': '负责人' }, + }; + expect(filter.label).toEqual({ en: 'Owner', 'zh-CN': '负责人' }); + }); + + it('accepts an inline per-locale map on a static option label', () => { + // The second, worse site: this one was not merely unrendered, it was + // DISCARDED by `normalizeFilterOptions` in every locale. + const filter: GlobalFilterOf = { + field: 'sales_channel', + name: 'channel', + type: 'select', + options: [{ value: 'domestic', label: { en: 'Domestic', 'zh-CN': '国内' } }], + }; + expect(filter.options).toHaveLength(1); + }); + + it('no longer DECLARES the bare-string option shorthand, which the spec rejects', () => { + // Measured, not assumed — `DashboardSchema.safeParse` on + // `options: ['EMEA', 'APAC']` returns two issues, both + // "Invalid input: expected object, received string". So the restatement was + // not only too narrow on `label`, it was also too WIDE here: it declared a + // shorthand `@objectstack/spec` refuses at publish, i.e. a second de-facto + // contract of exactly the kind AGENTS.md #0.1 forbids. A dashboard authored + // that way type-checked and rendered in objectui and would have been + // rejected the moment it reached the platform. + // + // Binding fixes both directions at once, which is the point of binding. + type _ShorthandGone = Assert< Equal< Accepts< 'options', string[] >, false > >; + + // NOT a runtime removal. `normalizeFilterOptions` in `@object-ui/core` + // still lifts a bare string for STORED documents — narrowing the authoring + // type and dropping tolerance for already-persisted metadata are different + // changes, and only the first is in this card's scope. The runtime half is + // filed separately. + expect(true).toBe(true); + }); + + it('is the spec type by identity, not a structural look-alike', () => { + // The load-bearing assertion. Every check above would also pass against a + // hand-written literal that happened to be widened by hand today — which is + // exactly how the drift was born the first time. This one can only pass + // while the binding itself is in place, so a future "just widen it locally" + // edit fails here rather than silently restarting the same divergence. + type SpecGlobalFilter = import('@objectstack/spec/ui').GlobalFilter; + type _Bound = Assert< Equal< GlobalFilterOf, SpecGlobalFilter > >; + expect(true).toBe(true); + }); +}); From 55d5dea58dcf4962e63d2f4f363d499280205d6d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 19:15:27 +0000 Subject: [PATCH 3/3] wip: changeset --- .changeset/metric-kpi-i18n-channel-4032.md | 45 ++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .changeset/metric-kpi-i18n-channel-4032.md diff --git a/.changeset/metric-kpi-i18n-channel-4032.md b/.changeset/metric-kpi-i18n-channel-4032.md new file mode 100644 index 0000000000..aa4de1b8d5 --- /dev/null +++ b/.changeset/metric-kpi-i18n-channel-4032.md @@ -0,0 +1,45 @@ +--- +'@object-ui/plugin-dashboard': patch +'@object-ui/core': patch +'@object-ui/types': patch +'@object-ui/app-shell': patch +--- + +fix(dashboard,i18n): KPI cards and dashboard filters resolve authored labels instead of dropping them (#4032) + +A `type: 'metric'` dashboard widget rendered raw English while every other widget +type on the same dashboard rendered the translation, and dashboard filter chips +rendered `[object Object]` or the raw stored value. Both come from the same +cause: authored labels reaching a render site that could not read the +vocabulary `@objectstack/spec` actually admits. + +- **KPI cards rejoin the widget translation channel.** The self-contained + `metric` branch built its own label from the raw `widget.title`, so the + `{ns}.dashboards.{dash}.widgets.{id}.title` value the renderer had already + resolved was computed and thrown away. It now reads that channel like every + other widget header. +- **The three private `resolveLabel` copies** (`DashboardRenderer`, + `MetricWidget`, `MetricCard`) are gone. Each read the retired + `{ key, defaultValue }` key-reference form and ended `defaultValue || key`, so + handed the inline per-locale map the spec admits today they returned nothing — + a KPI card with a map title rendered the literal string `metric`. All three + now use `pickLocalized`, the resolver already used for this vocabulary + elsewhere in the package. +- **Dashboard filter labels and static option labels resolve per locale.** + `DashboardFilterDef.label` widens to `string | I18nLabel`, the filter bar + resolves before rendering (fixing `[object Object]: All` in the trigger, and + in `aria-label` / `placeholder`), and the `def.label || def.name` gate now + tests the RESOLVED string — an object is always truthy, so it never reached + the fallback before. +- **Option labels are no longer discarded.** `normalizeFilterOptions` coerced a + map label to the raw stored value in every locale, English included, so + `{ value: 'domestic', label: { en: 'Domestic', … } }` displayed as `domestic`. + The pair shape is still normalized; the label vocabulary is preserved for the + render side to resolve. +- **`DashboardComponentSchema.globalFilters` is bound to the spec's + `GlobalFilter`** instead of restated by hand. The restatement was both too + narrow (`label?: string`, which is what made these read sites invisible to + `tsc`) and too wide (it declared a bare-string option shorthand the spec + rejects at publish). + +Plain-string labels are unaffected and render byte-identically.