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
45 changes: 45 additions & 0 deletions .changeset/metric-kpi-i18n-channel-4032.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,14 @@ export function DashboardWidgetInspector({
<div key={def.name} className="space-y-1.5" data-testid={`widget-filter-binding-${def.name}`}>
<div className="flex items-center justify-between gap-2">
<Label className="text-xs font-medium text-muted-foreground truncate">
{def.label || def.name}
{/* objectui#4032 — `DashboardFilterDef.label` widened to the
spec's `I18nLabel`, so this read (and the aria-label
below) resolve the inline per-locale map exactly as the
widget title above already does. Resolving BEFORE the
`||` also fixes the truthiness gate: an object is always
truthy, so a map that resolves to nothing never reached
`def.name`. */}
{resolveInlineI18nLabel(def.label, locale) || def.name}
</Label>
<InspectorCheckboxField
label={t('engine.inspector.widget.filterBindingApply', locale)}
Expand All @@ -386,7 +393,7 @@ export function DashboardWidgetInspector({
// (objectui#3997). Includes the filter name because a
// dashboard has several of these rows.
ariaLabel={tFormat('engine.inspector.widget.filterBindingField', locale, {
filter: def.label || def.name,
filter: resolveInlineI18nLabel(def.label, locale) || def.name,
})}
value={override}
onCommit={(v) => setBinding(v ? v : undefined)}
Expand Down
62 changes: 52 additions & 10 deletions packages/core/src/utils/dashboard-filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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) });
Expand Down
57 changes: 47 additions & 10 deletions packages/plugin-dashboard/src/DashboardFilterBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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, ' '));
Expand All @@ -86,7 +116,7 @@ function DateRangeFilter({ def, value, onChange }: { def: DashboardFilterDef; va
else onChange({ preset: v });
}}
>
<SelectTrigger className="h-8 w-auto min-w-36 gap-1" aria-label={def.label || tt('dashboard.filters.dateRange', 'Date range')}>
<SelectTrigger className="h-8 w-auto min-w-36 gap-1" aria-label={label || tt('dashboard.filters.dateRange', 'Date range')}>
<CalendarIcon className="size-3.5 opacity-60" />
<SelectValue placeholder={tt('dashboard.filters.dateRange', 'Date range')}>
{rangeLabel(value, presetLabel) ?? tt('dashboard.filters.allTime', 'All time')}
Expand Down Expand Up @@ -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<Array<{ value: string; label: string }> | null>(null);

// Dynamic options, server-side first (#2578 item 5): when the data source
Expand Down Expand Up @@ -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;
Expand All @@ -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<string>(value == null ? '' : String(value));
useEffect(() => { setDraft(value == null ? '' : String(value)); }, [value]);
const commit = () => {
Expand All @@ -259,12 +296,12 @@ function TextFilter({ def, value, onChange }: { def: DashboardFilterDef; value:
<Input
className="h-8 w-36"
type={def.type === 'number' ? 'number' : 'text'}
placeholder={def.label || def.name}
placeholder={label}
value={draft}
onChange={(e) => 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}`}
/>
);
Expand Down
Loading
Loading