Skip to content

Commit 3c557e2

Browse files
Trumpclaude
andauthored
fix(service-analytics): translate select dimension option labels on dataset charts (#17061)
* fix(service-analytics): translate select dimension option labels on dataset charts A dataset's select-field dimension rendered its option label straight out of authored field metadata (opt.label), which is never locale-aware — the option's label is always the author's own-language text (SelectOptionSchema.label is a plain string, never an inline locale map). A dotted cross-object dimension (field: 'contract.direction') was unaffected because a relationship-path field name never matches a key in the base object's own field map, so resolveDimensionLabels skips it entirely before either the select or lookup branch runs. DimensionLabelDeps gains one new optional capability, translateSelectOptions, wired in plugin.ts by calling the existing translateObject (@objectstack/spec/system) against the deployment's i18n bundle -- the same translator GET /meta/object/:name already uses, so a chart's category labels now match what the list grid renders for the identical field. No i18n service configured falls back to exactly today's authored-label behaviour. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * changeset(service-analytics): regrade to minor -- new key on the published DimensionLabelDeps DimensionLabelDeps is re-exported wholesale from index.ts, so translateSelectOptions is a new key on a published exported type -- the mechanical floor for clause 2 regardless of whether the key is optional. Backward compatible (additive, no removed/renamed key, no wire-shape change), so minor rather than major. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f801e7d commit 3c557e2

4 files changed

Lines changed: 266 additions & 4 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
**The published `DimensionLabelDeps` type (re-exported from this package's `index.ts`) gains
6+
one new optional key, `translateSelectOptions`** — the surface the level is graded against,
7+
per the same "a new key on a published exported type is the mechanical floor for clause ②"
8+
rule #16778 shipped under. Backward compatible (optional, additive, no removed/renamed key,
9+
no wire-shape change), so `minor` rather than `major`.
10+
11+
A dataset's `select`-field dimension now renders its option label in the request's locale on
12+
a dataset-backed chart, matching what `GET /meta/object/:name` (and hence the console's list
13+
grid) already renders for the identical field.
14+
15+
`dimension-labels.ts` resolved a select dimension's category label straight out of field
16+
metadata's authored `options[].label` — always the author's own-language text, since
17+
`SelectOptionSchema.label` is a plain string, never an inline locale map. The dotted
18+
cross-object arm (`field: 'contract.direction'`) was unaffected: a relationship-path field
19+
name never matches a key in the BASE object's own field map, so `resolveDimensionLabels`
20+
skips it via `if (!meta) continue` before either branch runs — this fix changes nothing on
21+
that path, and a regression test now pins that it is never even consulted.
22+
23+
`DimensionLabelDeps` gains one new optional capability, `translateSelectOptions`, which the
24+
plugin bridge (`plugin.ts`) implements by calling `translateObject` (`@objectstack/spec/system`)
25+
— the SAME translator the object-metadata REST endpoint already uses — against the
26+
deployment's i18n bundle, when an `i18n` service is registered. No new export, no new spec
27+
key, no wire-shape change: `AnalyticsResult` carries the same `rows`/`fields` shape as before,
28+
and a kernel with no i18n service configured (or nothing for the requested locale) falls back
29+
to exactly today's authored-label text.
30+
31+
A future widening of `LOOKUP_TYPES` (#16390) does **not** automatically inherit this: lookup /
32+
master_detail labels resolve through the separate `fetchRecordLabels` capability (a related
33+
RECORD's display name, not a field's authored `options[]`), which this change does not touch.
34+
It does lower the cost of adding translated lookup-record labels later, though — the i18n
35+
service bridge (`plugin.ts`'s `i18nService()` / `buildTranslationBundle()`) is now already
36+
wired into this package and is a `ctx.getService('i18n')` away from reuse.

‎packages/services/service-analytics/src/__tests__/dimension-labels.test.ts‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,76 @@ describe('resolveDimensionLabels', () => {
182182
expect(rows).toEqual([{ account: 'Acme Corp', n: 1 }]);
183183
});
184184
});
185+
186+
// ── #16773 — select option label i18n ───────────────────────────────────
187+
describe('select option i18n (#16773)', () => {
188+
it('routes a select dimension through translateSelectOptions when the plugin wires one, using the request locale', async () => {
189+
const seen: Array<{ objectName: string; fieldName: string; locale: string | undefined }> = [];
190+
const d = deps({
191+
translateSelectOptions: (objectName, fieldName, options, locale) => {
192+
seen.push({ objectName, fieldName, locale });
193+
return options.map((o) => (o.value === 'backlog' ? { ...o, label: '待办' } : o));
194+
},
195+
});
196+
const rows = [{ status: 'backlog', n: 1 }, { status: 'done', n: 2 }];
197+
await resolveDimensionLabels(
198+
'task',
199+
[{ name: 'status', field: 'status' }],
200+
rows,
201+
d,
202+
undefined,
203+
{ locale: 'zh-CN' } as any,
204+
);
205+
expect(seen).toEqual([{ objectName: 'task', fieldName: 'status', locale: 'zh-CN' }]);
206+
// Only the translated option changed; an option the translator didn't
207+
// touch (`done`) still renders its AUTHORED label ('Done') — from the
208+
// returned array, not silently dropped.
209+
expect(rows).toEqual([{ status: '待办', n: 1 }, { status: 'Done', n: 2 }]);
210+
});
211+
212+
it('falls back to the field\'s own authored label when translateSelectOptions declines (no i18n / nothing for this locale)', async () => {
213+
const d = deps({ translateSelectOptions: () => undefined });
214+
const rows = [{ status: 'backlog', n: 1 }];
215+
await resolveDimensionLabels(
216+
'task',
217+
[{ name: 'status', field: 'status' }],
218+
rows,
219+
d,
220+
undefined,
221+
{ locale: 'fr-FR' } as any,
222+
);
223+
expect(rows).toEqual([{ status: 'Backlog', n: 1 }]);
224+
});
225+
226+
it('behaves exactly as before when the plugin declares no translateSelectOptions capability at all', async () => {
227+
const rows = [{ status: 'backlog', n: 1 }];
228+
await resolveDimensionLabels('task', [{ name: 'status', field: 'status' }], rows, deps() /* no translator */);
229+
expect(rows).toEqual([{ status: 'Backlog', n: 1 }]);
230+
});
231+
232+
// The dotted/cross-object CONTROL (#16773): a relationship-path field name
233+
// (`account.region`) never matches a key in the BASE object's own field
234+
// map, so `resolveDimensionLabels` skips it entirely via `if (!meta)
235+
// continue` — select-branch translation included. This is the measured
236+
// reason the dotted arm needs no change: it never reaches this file's
237+
// select branch in the first place, on EITHER strategy, regardless of
238+
// `translateSelectOptions`.
239+
it('a dotted cross-object field name never matches the base object field map — left untouched, translateSelectOptions never consulted', async () => {
240+
let called = false;
241+
const d = deps({ translateSelectOptions: () => { called = true; return undefined; } });
242+
const rows = [{ region: 'backlog', n: 1 }]; // arbitrary raw value; must survive verbatim
243+
await resolveDimensionLabels(
244+
'task',
245+
[{ name: 'region', field: 'account.region' }],
246+
rows,
247+
d,
248+
undefined,
249+
{ locale: 'zh-CN' } as any,
250+
);
251+
expect(called).toBe(false);
252+
expect(rows).toEqual([{ region: 'backlog', n: 1 }]);
253+
});
254+
});
185255
});
186256

187257
describe('formatDateBucket', () => {
@@ -301,6 +371,41 @@ describe('AnalyticsService.queryDataset — label resolution (integration)', ()
301371
}]);
302372
});
303373

374+
it('#16773 — a same-object select dimension renders the LOCALIZED option label end to end when the plugin bridge wires translateSelectOptions', async () => {
375+
const svc = new AnalyticsService({
376+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
377+
executeAggregate: async () => [
378+
{ status: 'backlog', task_count: 5 },
379+
{ status: 'done', task_count: 3 },
380+
],
381+
labelResolver: {
382+
getObjectFields: (obj) => (obj === 'task' ? TASK_FIELDS : undefined),
383+
fetchRecordLabels: async () => new Map(),
384+
translateSelectOptions: (objectName, fieldName, options, locale) => {
385+
if (objectName !== 'task' || fieldName !== 'status' || locale !== 'zh-CN') return undefined;
386+
const zh: Record<string, string> = { backlog: '待办', in_review: '审核中', done: '完成' };
387+
return options.map((o) => (typeof o.value === 'string' && zh[o.value] ? { ...o, label: zh[o.value] } : o));
388+
},
389+
},
390+
});
391+
const statusOnly = DatasetSchema.parse({
392+
name: 'task_status',
393+
label: 'Task Status',
394+
object: 'task',
395+
dimensions: [{ name: 'status', field: 'status', type: 'string' }],
396+
measures: [{ name: 'task_count', aggregate: 'count' }],
397+
});
398+
const res = await svc.queryDataset(
399+
statusOnly,
400+
{ dimensions: ['status'], measures: ['task_count'] },
401+
{ tenantId: 'org_A', locale: 'zh-CN' } as any,
402+
);
403+
expect(res.rows).toEqual([
404+
{ status: '待办', task_count: 5 },
405+
{ status: '完成', task_count: 3 },
406+
]);
407+
});
408+
304409
it('enriches measure fields with their display label + format', async () => {
305410
const labelledDataset = DatasetSchema.parse({
306411
name: 'sales_metrics',

‎packages/services/service-analytics/src/dimension-labels.ts‎

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,13 @@
1818
* rather than blanking out. Date / number / plain-string dimensions are no-ops.
1919
*
2020
* The resolution LOGIC lives here (and is unit-tested); the low-level capabilities
21-
* — reading an object's field map and fetching id→label pairs — are injected via
22-
* {@link DimensionLabelDeps} so this module stays free of any engine dependency.
21+
* — reading an object's field map, fetching id→label pairs, and translating a
22+
* select option's label (#16773) — are injected via {@link DimensionLabelDeps}
23+
* so this module stays free of any engine OR i18n dependency: a select option's
24+
* label is looked up in a translation bundle by the SAME translator the
25+
* object-metadata REST endpoint uses (`translateObject`, `@objectstack/spec/system`),
26+
* called from the plugin bridge (`plugin.ts`) — not reimplemented here, so
27+
* there stays exactly ONE copy of "translate a select option label".
2328
*/
2429

2530
import type { ExecutionContext } from '@objectstack/spec/kernel';
@@ -64,6 +69,34 @@ export interface DimensionLabelDeps {
6469
scope?: Record<string, unknown>,
6570
context?: ExecutionContext,
6671
): Promise<Map<unknown, string>>;
72+
/**
73+
* Translate a `select` field's authored `options[]` into `locale` (#16773).
74+
*
75+
* A select option's `label` (`SelectOptionSchema.label`, `packages/spec`) is
76+
* a PLAIN string — never an inline `I18nLabel` map — so its translation, if
77+
* any, lives in an i18n TRANSLATION BUNDLE keyed
78+
* `objects.<object>.fields.<field>.options.<value>`, the same address
79+
* `translateObject` (`@objectstack/spec/system`) resolves for
80+
* `GET /meta/object/:name` (the object-metadata REST endpoint the console's
81+
* list/kanban/grid renderers already read their translated option labels
82+
* from). This hook is how that SAME translator reaches an analytics
83+
* dimension's option labels too — implemented once, in the plugin bridge
84+
* (`plugin.ts`), by calling `translateObject` itself; nothing here
85+
* reimplements the lookup.
86+
*
87+
* Optional, and `undefined` (no i18n service registered, or nothing for
88+
* this object/field/locale) means "no translation available" — the caller
89+
* then falls back to the field's own authored `options[].label`, exactly
90+
* the pre-existing (locale-blind) behaviour. `locale` is threaded PER CALL,
91+
* never captured when `DimensionLabelDeps` is built, because one instance
92+
* is reused across every request.
93+
*/
94+
translateSelectOptions?(
95+
objectName: string,
96+
fieldName: string,
97+
options: Array<{ value: unknown; label?: string }>,
98+
locale: string | undefined,
99+
): Array<{ value: unknown; label?: string }> | undefined;
67100
}
68101

69102
/**
@@ -201,6 +234,17 @@ export function withLabelFetchCache(deps: DimensionLabelDeps): DimensionLabelDep
201234
}
202235
return out;
203236
},
237+
// #16773 — passed straight through: nothing here is id-keyed request
238+
// state to cache, and dropping the capability at this wrapper (as an
239+
// earlier version of this fix did) silently disabled it for every real
240+
// `AnalyticsService.queryDataset` call, which always wraps `labelResolver`
241+
// in this cache (`analytics-service.ts`) — only a hand-rolled `deps()` in
242+
// a unit test bypasses it, which is exactly why that gap did not show up
243+
// until the end-to-end test below was added.
244+
translateSelectOptions: deps.translateSelectOptions
245+
? (objectName, fieldName, options, locale) =>
246+
deps.translateSelectOptions!(objectName, fieldName, options, locale)
247+
: undefined,
204248
};
205249
}
206250

@@ -310,8 +354,16 @@ export async function resolveDimensionLabels(
310354

311355
// ── select: value → option label ──────────────────────────────────
312356
if (Array.isArray(meta.options) && meta.options.length > 0) {
357+
// #16773 — the field's own `options[].label` is the AUTHORED (usually
358+
// English) text; consult the i18n translation bundle for this request's
359+
// locale first, via the SAME translator `GET /meta/object/:name` uses,
360+
// and fall back to the authored label when no translation is available
361+
// (no i18n service configured, nothing for this locale, or the option's
362+
// value carries no bundle entry at all).
363+
const translated = deps.translateSelectOptions?.(baseObject, dim.field, meta.options, context?.locale);
364+
const options = translated ?? meta.options;
313365
const labelByValue = new Map<unknown, string>();
314-
for (const opt of meta.options) {
366+
for (const opt of options) {
315367
if (opt && opt.label != null) labelByValue.set(opt.value, String(opt.label));
316368
}
317369
if (labelByValue.size === 0) continue;

‎packages/services/service-analytics/src/plugin.ts‎

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import type { Plugin, PluginContext } from '@objectstack/core';
44
import type { Cube, FilterCondition } from '@objectstack/spec/data';
55
import { AggregationFunction } from '@objectstack/spec/data';
66
import type { ExecutionContext } from '@objectstack/spec/kernel';
7-
import type { IAnalyticsService, IDataDriver, IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts';
7+
import type { IAnalyticsService, IDataDriver, IDataEngine, IObjectQLEngine, II18nService } from '@objectstack/spec/contracts';
8+
import { translateObject, type ObjectLike, type ObjectFieldLike, type TranslationBundle } from '@objectstack/spec/system';
89
import { AnalyticsService } from './analytics-service.js';
910
import type { AnalyticsServiceConfig } from './analytics-service.js';
1011
import type { AnalyticsDriverCapabilities } from './strategies/types.js';
@@ -732,6 +733,38 @@ export class AnalyticsServicePlugin implements Plugin {
732733
return svc && typeof svc.getObject === 'function' ? svc : undefined;
733734
} catch { return undefined; }
734735
};
736+
737+
// #16773 — a select option's `label` (`SelectOptionSchema.label`) is a
738+
// PLAIN authored string; its translation, if any, lives in an i18n
739+
// TRANSLATION BUNDLE, not on the field metadata `getObjectFields` reads.
740+
// Resolved lazily, same as `dataEngine` above, so plugin-init order is
741+
// free and a kernel with no i18n service configured degrades to exactly
742+
// today's (locale-blind, authored-label) behaviour.
743+
const i18nService = (): II18nService | undefined => {
744+
try {
745+
const svc = ctx.getService<II18nService>('i18n');
746+
return svc && typeof svc.getTranslations === 'function' && typeof svc.getLocales === 'function'
747+
? svc
748+
: undefined;
749+
} catch { return undefined; }
750+
};
751+
// Mirrors `RestServer.buildTranslationBundle` (`packages/rest`) — this
752+
// package has no dependency on `packages/rest`, so the ~10-line glue that
753+
// turns an `II18nService` into a `TranslationBundle` is rebuilt here
754+
// against the SAME public `II18nService` surface. This is NOT a second
755+
// "translate a select option label" implementation: the actual lookup
756+
// stays exactly one function, `translateObject` below, imported rather
757+
// than reimplemented.
758+
const buildTranslationBundle = (i18n: II18nService): TranslationBundle | undefined => {
759+
const locales = i18n.getLocales();
760+
if (!locales.length) return undefined;
761+
const bundle: TranslationBundle = {};
762+
for (const locale of locales) {
763+
const data = i18n.getTranslations(locale);
764+
if (data && typeof data === 'object') (bundle as Record<string, unknown>)[locale] = data;
765+
}
766+
return Object.keys(bundle).length ? bundle : undefined;
767+
};
735768
const labelResolver: DimensionLabelDeps = {
736769
getObjectFields: (objectName) => dataEngine()?.getObject?.(objectName)?.fields,
737770
fetchRecordLabels: async (targetObject, ids, scope, context) => {
@@ -794,6 +827,42 @@ export class AnalyticsServicePlugin implements Plugin {
794827
}
795828
return map;
796829
},
830+
// #16773 — route a select option label through the SAME translator the
831+
// object-metadata REST endpoint uses (`GET /meta/object/:name`, which
832+
// is where the console's list/kanban/grid renderers get theirs), so a
833+
// chart's category/series labels match what those surfaces render for
834+
// the identical field. `undefined` (no i18n service, no locales
835+
// declared, or nothing in the bundle for this locale) leaves the
836+
// caller's authored-label fallback untouched.
837+
translateSelectOptions: (objectName, fieldName, options, locale) => {
838+
if (!locale) return undefined;
839+
const i18n = i18nService();
840+
if (!i18n) return undefined;
841+
const bundle = buildTranslationBundle(i18n);
842+
if (!bundle) return undefined;
843+
const fallback = typeof i18n.getFallbackLocale === 'function' ? i18n.getFallbackLocale() : undefined;
844+
const defaultLocale = typeof i18n.getDefaultLocale === 'function' ? i18n.getDefaultLocale() : undefined;
845+
// `value` here is `unknown` (an option's stored value, of whatever
846+
// shape the field declares); `ObjectFieldLike.options[].value` narrows
847+
// to `string | number | boolean` (`SelectOptionSchema.value`'s real
848+
// runtime type). The cast is a type-only widening back to what this
849+
// capability's own signature promises — no value is coerced.
850+
const fields: Record<string, ObjectFieldLike> = {
851+
[fieldName]: { name: fieldName, options: options as ObjectFieldLike['options'] },
852+
};
853+
const doc: ObjectLike = { name: objectName, fields };
854+
const translated = translateObject(doc, bundle, {
855+
locale,
856+
fallbackChain: typeof fallback === 'string' && fallback.length > 0 ? [fallback] : undefined,
857+
defaultLocale: typeof defaultLocale === 'string' && defaultLocale.length > 0 ? defaultLocale : undefined,
858+
});
859+
const translatedField = Array.isArray(translated.fields)
860+
? translated.fields.find((f) => f && f.name === fieldName)
861+
: translated.fields?.[fieldName];
862+
return Array.isArray(translatedField?.options)
863+
? (translatedField.options as typeof options)
864+
: undefined;
865+
},
797866
};
798867

799868
// ADR-0037 P3 — draft data preview: resolve the PENDING seed draft's rows

0 commit comments

Comments
 (0)