Found while implementing #5264 (PR migrating ObjectMetricWidgetProps to string | I18nLabel). Not fixed there — different member surface, and the fix needs a decision this card records rather than guesses.
What
packages/plugin-dashboard/src/WidgetConfigPanel.tsx carries a private label resolver:
/** Resolve an I18nLabel (string or {key, defaultValue}) to a plain string. */functionresolveLabel(v: unknown): string{if(v===undefined||v===null)return'';if(typeofv==='string')returnv;if(typeofv==='object'){constobj=vasRecord<string,any>;returnobj.defaultValue||obj.key||'';}returnString(v);}Its doc comment claims it resolves an I18nLabel. It does the opposite: { key, defaultValue } is the key-reference form @objectstack/spec RETIRED at 17.0.0-rc.6 (objectstack#5055), and the inline per-locale map that I18nLabelSchema actually admits has neither limb. Measured against this tree:
| input | resolveLabel returns |
|---|
{ en: 'Revenue', 'zh-CN': ... } — the spec's vocabulary | "" |
{ key: 'a.b.c', defaultValue: 'Revenue' } — retired | "Revenue" |
'Revenue' | "Revenue" |
It is the same private-resolver class objectui#4032 swept out of DashboardRenderer, MetricWidget and MetricCard; this fourth copy was not in that pass. It is also the mirror of #5134 — a declared I18nLabel surface whose only read site cannot read the form the spec admits.
Why it is not merely cosmetic
The resolved value is not display-only — it seeds the editable draft:
constnormalizedConfig=React.useMemo(()=>({
...config,title: typeofconfig.title==='object' ? resolveLabel(config.title) : config.title,description: typeofconfig.description==='object' ? resolveLabel(config.description) : config.description,}),[config]);const{ draft, isDirty, updateField, discard }=useConfigDraft(normalizedConfig, ...);So for a widget whose stored title is an inline locale map, opening the config panel shows an EMPTY Title field, and the draft that a save writes back carries '' where the author's map used to be. The panel is the surface Studio authors dashboards through, so the destructive path is the ordinary one — open the widget, change anything else, save.
Why it was not folded into #5264
That PR's fix shape is binding and scoped to ObjectMetricWidgetProps plus the fixture sweep. This is a different member surface (DashboardWidget config, not the widget's own props), and — unlike the four members there — the fix is not mechanical: resolveLabel is a module-level function with no locale in scope, so making it locale-aware means deciding which locale an authoring panel should display, and what a save should then write back. Those are not the same question:
- Reading:
pickLocalized(value, language) matches every sibling surface. - Writing: collapsing a multi-locale map to the one string the editor showed silently discards the other locales. Preserving them needs the panel to edit a map, or to write back only the active locale's entry.
The second half is a real design decision (arguably a Studio authoring-UX question, cf. #4163's "give Studio a way to author one"), which is why this is filed rather than guessed.
Repro
resolveLabel({en: 'Revenue','zh-CN': '收入'})// => "" (expected "Revenue" / "收入")Note the pure-function repro is the honest one here: the panel path reaches it through normalizedConfig, so a component-level test would also be asserting useConfigDraft's behaviour.
Found while implementing #5264 (PR migrating
ObjectMetricWidgetPropstostring | I18nLabel). Not fixed there — different member surface, and the fix needs a decision this card records rather than guesses.What
packages/plugin-dashboard/src/WidgetConfigPanel.tsxcarries a private label resolver:Its doc comment claims it resolves an
I18nLabel. It does the opposite:{ key, defaultValue }is the key-reference form@objectstack/specRETIRED at 17.0.0-rc.6 (objectstack#5055), and the inline per-locale map thatI18nLabelSchemaactually admits has neither limb. Measured against this tree:resolveLabelreturns{ en: 'Revenue', 'zh-CN': ... }— the spec's vocabulary""{ key: 'a.b.c', defaultValue: 'Revenue' }— retired"Revenue"'Revenue'"Revenue"It is the same private-resolver class objectui#4032 swept out of
DashboardRenderer,MetricWidgetandMetricCard; this fourth copy was not in that pass. It is also the mirror of #5134 — a declaredI18nLabelsurface whose only read site cannot read the form the spec admits.Why it is not merely cosmetic
The resolved value is not display-only — it seeds the editable draft:
So for a widget whose stored
titleis an inline locale map, opening the config panel shows an EMPTY Title field, and the draft that a save writes back carries''where the author's map used to be. The panel is the surface Studio authors dashboards through, so the destructive path is the ordinary one — open the widget, change anything else, save.Why it was not folded into #5264
That PR's fix shape is binding and scoped to
ObjectMetricWidgetPropsplus the fixture sweep. This is a different member surface (DashboardWidgetconfig, not the widget's own props), and — unlike the four members there — the fix is not mechanical:resolveLabelis a module-level function with no locale in scope, so making it locale-aware means deciding which locale an authoring panel should display, and what a save should then write back. Those are not the same question:pickLocalized(value, language)matches every sibling surface.The second half is a real design decision (arguably a Studio authoring-UX question, cf. #4163's "give Studio a way to author one"), which is why this is filed rather than guessed.
Repro
Note the pure-function repro is the honest one here: the panel path reaches it through
normalizedConfig, so a component-level test would also be assertinguseConfigDraft's behaviour.