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
46 changes: 46 additions & 0 deletions .changeset/10132-dashboard-i18n-declared-not-resolved.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
'@object-ui/core': minor
'@object-ui/plugin-dashboard': patch
---

fix(dashboard,charts): two dashboard surfaces the spec types as translatable now resolve (objectui#10132)

`@objectstack/spec` declares both of these translatable and the Console resolved neither, so
an author could set a key the contract documents and nothing happened. They turned out to be
**two defects, not one** — a key never read, and a value that bypassed a resolver already in
the tree — and they are fixed separately.

**1. `GlobalFilterSchema.object` was never read.** The spec's describe text for the key is
"Object whose `fields.<object>.<field>` translation-bundle entry resolves this filter's field
label and option labels". `resolveDashboardFilterDefs` names the keys it copies onto a
`DashboardFilterDef` and this one was not among them, so the authored value could not reach a
renderer even in principle: a dashboard filter declaring `object` and no `label`, viewed on a
translated console, painted the RAW FIELD NAME and left its option labels in the authored
English. The definition now carries `object` through, and `DashboardFilterBar` resolves the
field label through `useSafeFieldLabel().fieldLabel` and the option labels through the same
object's `translateOptions` — the convention resolver every list and form already calls, which
is the "one resolver path" the key's own spec text asks for. No second resolver was written.
Precedence follows that resolver's signature: the translator's bundle wins, the authored label
is the fallback. A filter that names no `object` never reaches it and renders exactly as
before.

**2. `ChartAxisSchema.title` bypassed a resolver two modules downstream.** The key is
`I18nLabel`, so an inline locale map parses, builds and validates — and `axisPresentation`
collapsed it with `labelText`, a first-string-in-KEY-ORDER pick. Measured both ways round on
one map: English to a `zh-CN` viewer and Chinese to an `en` viewer, decided by nothing but
which key the author typed first. `normalizeChartSchema` already resolves an axis title
through `pickLocalized` against the viewer's language, so the map only had to survive the
lowering. It now travels verbatim through `forwardedI18nLabel` — the neighbour in the same
module that already carries a chart's own `title` / `subtitle` / `description` for exactly
this reason.

This moves one entry of the objectui#4020 first-string-wins ledger. That ledger's
justification is "a locale-unaware pick a caller can OVERRIDE", which holds for a series
`label` — `DatasetWidget` replaces it from the locale bundle — and never held for an axis
title, which is spread onto the chart schema and drawn. `seriesPresentation` keeps the pick and
is still pinned.

What an author sees change: a filter that opted in with `object` now shows its translated
field and option labels instead of the raw field name and English options; an axis title
authored as a locale map now follows the viewer's language instead of the author's key order.
Nothing that omits `object` or authors a plain-string axis title renders differently.
15 changes: 13 additions & 2 deletions content/docs/guide/dashboard-filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,12 @@ Add a `globalFilters` entry. Each entry renders one control in the filter bar:
under, and the key widgets reference in `filterBindings`. Defaults to
`field`. (`"dateRange"` is reserved for the built-in date range.)
- `field` — the default field the filter applies to on bound widgets.
- `object` — optional: the object `field` lives on. Declaring it opts the
filter into that object's translations: its field label and option labels
resolve through the same `fields.<object>.<field>` translation-bundle
convention lists and forms use, with `label` as the fallback (see
[i18n](#i18n)). Not the same key as `optionsFrom.object` below, which names
the object dynamic options are fetched from.
- `type` — the control type: `text`, `number`, `select`, `lookup`, or `date`.

| Type | Control | Generated condition |
Expand Down Expand Up @@ -334,8 +340,13 @@ stay in sync.
## i18n

The filter bar's strings resolve from the `dashboard.filters.*` keys
(`@object-ui/i18n` ships `en` and `zh` entries — control labels come from each
filter's `label`, so translate those in your schema metadata).
(`@object-ui/i18n` ships `en` and `zh` entries). A control's label comes from
its filter's `label`, so translate that in your schema metadata — unless the
filter declares `object`. Then the app's translation bundle wins: the field
label resolves from the `fields.<object>.<field>` entry and each option label
from `fieldOptions.<object>.<field>.<value>`, the same convention lists and
forms use, and the authored `label` (the option's own `label`, for an option)
is only the fallback when the bundle has no entry.

## Spec alignment

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* 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#10132, half 2 — the SEAM half.
*
* `ChartAxisSchema.title` is `I18nLabel`, so `{ en: 'Revenue', 'zh-CN': '收入' }`
* parses, builds and validates. `axisPresentation` collapsed it with
* `labelText` — a first-string-in-KEY-ORDER pick — so the axis title was
* decided by the order the author happened to type the map in, and the
* VIEWER's language never entered the decision. Measured on the reported
* surface: the same map rendered English to a `zh-CN` viewer and Chinese to an
* `en` viewer, depending only on which key came first.
*
* ## The fix is the neighbour's, not a new one
*
* `forwardedI18nLabel` in the same module already carries a chart's
* `title` / `subtitle` / `description` through unresolved (objectui#9038), for
* the reason stated there: `@object-ui/core` is React-free, holds no provider,
* and cannot know the viewer — while `normalizeChartSchema` downstream ALREADY
* resolves an axis title through `pickLocalized` against the language
* `ChartRenderer` reads. So the map only has to survive the lowering to reach a
* resolver that was there all along. This is a bypass, not a missing resolver.
*
* ⚠️ This moves one entry of the objectui#4020 ledger that
* `chart-presentation.i18nLabel-9038.test.ts` pinned as deliberately picked.
* That ledger's justification is "a locale-unaware CHOICE a caller can
* OVERRIDE" — and it holds for a series `label`, which `DatasetWidget`
* overrides from the locale bundle. Nothing overrides an axis title: it is
* spread onto the chart schema and drawn. The series half therefore stays
* picked and is re-pinned below, so the asymmetry stays visible.
*
* ## What this file pins, and what it deliberately does not
*
* The seam only: the authored union survives the lowering. It cannot pin which
* limb a viewer sees — that is `pickLocalized`'s decision, downstream. The
* rendered half is `ChartRenderer.axisTitleLocale-10132.test.tsx` in
* `@object-ui/plugin-charts`, which draws the same map at two languages.
*
* ## The key-order control
*
* Every map below is written **`en` first**, so a `zh-CN` expectation cannot be
* satisfied by the first-string pick this replaces.
*
* PREDICTIONS, written before the run: the forwarding cases are RED (the
* lowering hands back the string `'Revenue'`, not the map); the plain-string,
* refusal, untouched-key and series-label cases are GREEN on both sides.
*/

import { describe, it, expect } from 'vitest';
import {
axisPresentation,
mergeAuthoredPresentation,
seriesPresentation,
} from '../chart-presentation';

/** ⚠️ `en` FIRST — see the key-order control above. */
const MAP = { en: 'Revenue', 'zh-CN': '收入' };

describe('axisPresentation — an inline-locale-map axis title survives the lowering (objectui#10132)', () => {
it('forwards a locale-map `title` verbatim, for the renderer to resolve', () => {
// Identity, not equality: the authored value travels untouched, so no
// renderer-side coercion can have happened on the way through.
expect(axisPresentation({ field: 'total', title: MAP }).title).toBe(MAP);
});

it('leaves a plain-string `title` exactly as it was — the live control', () => {
// The arm that already worked. It is in this run so that "the map arm now
// travels" cannot be bought by changing what a string does.
expect(axisPresentation({ field: 'total', title: 'Revenue' }).title).toBe('Revenue');
});

it('still refuses every value the union does not admit', () => {
for (const value of [42, true, ['Revenue'], '', {}, { en: '' }, { en: 42 }, null, undefined]) {
const out = axisPresentation({ field: 'total', title: value });
expect(Object.prototype.hasOwnProperty.call(out, 'title')).toBe(false);
}
});

it('admits a map on the strength of one usable entry and carries the rest as authored', () => {
// `InlineLocaleMapSchema` is `z.record(<tag>, z.string())`; enforcing that
// belongs at the parse, not in a renderer-side coercion (AGENTS.md #0.1).
const mixed = { en: 'Revenue', 'zh-CN': 7 };
expect(axisPresentation({ field: 'total', title: mixed }).title).toBe(mixed);
});

it('changes no other key on the same axis', () => {
// The harm this must not cause: a key other than `title` moving.
const axis = {
field: 'total',
format: 'currency',
min: 0,
max: 100,
stepSize: 10,
showGridLines: false,
position: 'right',
logarithmic: true,
title: MAP,
};
const withMap = axisPresentation(axis);
const withString = axisPresentation({ ...axis, title: 'Revenue' });
for (const [k, v] of Object.entries(withString)) {
if (k === 'title') continue;
expect(withMap[k]).toEqual(v);
}
expect(Object.keys(withMap).sort()).toEqual(Object.keys(withString).sort());
});

it('forwards through the entry point a dataset-bound surface calls, on BOTH axes', () => {
// `mergeAuthoredPresentation` is what `DatasetWidget` calls; reading
// `axisPresentation` alone would pin a function no surface reaches directly.
const { axes } = mergeAuthoredPresentation([{ dataKey: 'total', label: 'Total' }] as never, {
xAxis: { field: 'status', title: MAP },
yAxis: [{ field: 'total', title: MAP }],
});

expect((axes.xAxis as Record<string, unknown>).title).toBe(MAP);
expect((axes.yAxis as Array<Record<string, unknown>>)[0].title).toBe(MAP);
});

it('leaves the series label PICKED, not forwarded — the ledgered neighbour', () => {
// objectui#4020's first-string-wins pick for a series `label` is a
// different question and stays where it is: `DatasetWidget` overrides that
// label from the locale bundle, so the pick is a value a caller replaces
// rather than a value a viewer sees. Pinned here so this card cannot be
// read as having moved both.
expect(seriesPresentation({ name: 'total', label: MAP }).label).toBe('Revenue');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,31 @@ describe('chartConfigPresentation — an inline-locale-map heading survives the
expect(Object.keys(withMap).sort()).toEqual(Object.keys(withString).sort());
});

it('leaves the ledgered neighbours picked, not forwarded', () => {
// objectui#4020's first-string-wins pick for a series `label` and an axis
// `title` is a DIFFERENT question — a locale-unaware CHOICE a caller can
// override, not an erasure — and is out of this card's scope. Pinned here
// so a later edit cannot quietly fold them in under this card's banner, and
// so the asymmetry is visible rather than inferred: these two resolve to a
// string HERE, the three chrome keys resolve at the renderer.
it('leaves the ledgered series label picked, not forwarded', () => {
// objectui#4020's first-string-wins pick for a series `label` is a
// DIFFERENT question — a locale-unaware CHOICE a caller can override, not
// an erasure — and is out of this card's scope. Pinned here so a later edit
// cannot quietly fold it in under this card's banner, and so the asymmetry
// is visible rather than inferred: this one resolves to a string HERE, the
// three chrome keys resolve at the renderer.
//
// `DatasetWidget` is the caller the ledger's justification names: it
// replaces a series label from the locale bundle, so the pick is a value
// that gets overridden rather than a value a viewer sees.
expect(seriesPresentation({ name: 'total', label: MAP }).label).toBe('Pricing');
expect(axisPresentation({ field: 'total', title: MAP }).title).toBe('Pricing');
});

it('no longer holds the AXIS title to that pick — moved by objectui#10132, not quietly', () => {
// This assertion used to sit in the case above, reading `.toBe('Pricing')`.
// objectui#10132 measured what the ledger's justification assumed and found
// it absent on this arm: nothing overrides an axis title — it is spread
// onto the chart schema and drawn — so the pick was not a choice a caller
// could correct, it was the rendered answer. `axisPresentation` now
// forwards the union for `normalizeChartSchema` to resolve.
//
// Kept HERE rather than deleted so this file stays the ledger it was
// written to be: the asymmetry it records is now one picked slot and one
// forwarded one, and a reader sees which moved and under which card.
expect(axisPresentation({ field: 'total', title: MAP }).title).toBe(MAP);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* 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#10132, half 1 — the SEAM half.
*
* `@objectstack/spec`'s `GlobalFilterSchema` carries an optional `object` key
* whose own describe text is
* "Object whose `fields.<object>.<field>` translation-bundle entry resolves
* this filter's field label and option labels". `resolveDashboardFilterDefs`
* built its `DashboardFilterDef` by naming the keys it copies, and `object`
* was not among them — so the authored value could not reach a renderer even
* in principle. That is the declared-but-unenforced shape: authorable,
* documented, validated, and inert.
*
* ## What this file pins, and what it deliberately does not
*
* The seam only: the authored key survives the normalization. It cannot pin
* that a viewer sees a translated label — `@object-ui/core` is locale-free by
* design (see `DashboardFilterDef.label`'s own comment) and holds no bundle.
* The rendered half is `DashboardFilterBar.objectBundleLabel-10132.test.tsx`
* in `@object-ui/plugin-dashboard`, which renders the same filter with the
* bundle entry present and absent and asserts the two differ.
*
* A green run here over a filter bar that never reads the key would be a
* correct seam feeding no consumer at all — which is precisely the state the
* card reports — so neither half stands alone.
*
* PREDICTIONS, written before the run: the two `object` cases are RED (the key
* is absent from the def); the "no `object` authored" and untouched-key cases
* are GREEN on both sides — they pin decisions that must NOT move.
*/

import { describe, it, expect } from 'vitest';
import { resolveDashboardFilterDefs } from '../dashboard-filters';

describe('resolveDashboardFilterDefs — the spec `object` key reaches the definition (objectui#10132)', () => {
it('copies an authored `object` onto the filter definition', () => {
const [def] = resolveDashboardFilterDefs({
globalFilters: [{ name: 'type', field: 'type', object: 'opportunity', type: 'select' }],
} as never);

expect(def.object).toBe('opportunity');
});

it('carries it for a filter that also declares a label and options', () => {
// The key is additive: it does not replace either of the two slots that
// already resolve, it names the bundle entry they are looked up under.
const [def] = resolveDashboardFilterDefs({
globalFilters: [
{
name: 'type',
field: 'type',
object: 'opportunity',
type: 'select',
label: 'Type',
options: [{ value: 'new_business', label: 'New business' }],
},
],
} as never);

expect(def.object).toBe('opportunity');
expect(def.label).toBe('Type');
expect(def.options).toEqual([{ value: 'new_business', label: 'New business' }]);
});

it('leaves `object` absent when the author declared none — the live control', () => {
// The acceptance boundary: every dashboard authored today resolves to a
// byte-identical def, and `'object' in def` stays the discriminator a
// consumer branches on.
const [def] = resolveDashboardFilterDefs({
globalFilters: [{ name: 'type', field: 'type', type: 'select', label: 'Type' }],
} as never);

expect(def.object).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(def, 'object')).toBe(false);
});

it('does not confuse it with `optionsFrom.object`, which names a different object', () => {
// The spec's own text separates the two: `optionsFrom.object` names where
// dynamic OPTIONS are fetched from (it may differ — filtering `opportunity`
// by `owner` with options sourced from `user`), while this key names the
// object `field` itself lives on, which is what a translator's bundle entry
// is keyed by. A fix that read `optionsFrom.object` for label resolution
// would resolve against the wrong object for exactly that filter.
const [def] = resolveDashboardFilterDefs({
globalFilters: [
{
name: 'owner',
field: 'owner',
object: 'opportunity',
type: 'lookup',
optionsFrom: { object: 'user', valueField: 'id', labelField: 'name' },
},
],
} as never);

expect(def.object).toBe('opportunity');
expect(def.optionsFrom?.object).toBe('user');
});
});
Loading
Loading