From 59a383db578d13e86b2c46f4abba786aec96442c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 04:59:04 +0000 Subject: [PATCH 1/2] fix(service-analytics): translate select dimension option labels on dataset charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- .../dataset-select-dimension-option-i18n.md | 30 +++++ .../src/__tests__/dimension-labels.test.ts | 105 ++++++++++++++++++ .../service-analytics/src/dimension-labels.ts | 58 +++++++++- .../services/service-analytics/src/plugin.ts | 71 +++++++++++- 4 files changed, 260 insertions(+), 4 deletions(-) create mode 100644 .changeset/dataset-select-dimension-option-i18n.md diff --git a/.changeset/dataset-select-dimension-option-i18n.md b/.changeset/dataset-select-dimension-option-i18n.md new file mode 100644 index 0000000000..cb706c44a6 --- /dev/null +++ b/.changeset/dataset-select-dimension-option-i18n.md @@ -0,0 +1,30 @@ +--- +"@objectstack/service-analytics": patch +--- + +A dataset's `select`-field dimension now renders its option label in the request's locale on +a dataset-backed chart, matching what `GET /meta/object/:name` (and hence the console's list +grid) already renders for the identical field. + +`dimension-labels.ts` resolved a select dimension's category label straight out of field +metadata's authored `options[].label` — always the author's own-language text, since +`SelectOptionSchema.label` is a plain string, never an inline locale map. The dotted +cross-object arm (`field: 'contract.direction'`) was unaffected: a relationship-path field +name never matches a key in the BASE object's own field map, so `resolveDimensionLabels` +skips it via `if (!meta) continue` before either branch runs — this fix changes nothing on +that path, and a regression test now pins that it is never even consulted. + +`DimensionLabelDeps` gains one new optional capability, `translateSelectOptions`, which the +plugin bridge (`plugin.ts`) implements by calling `translateObject` (`@objectstack/spec/system`) +— the SAME translator the object-metadata REST endpoint already uses — against the +deployment's i18n bundle, when an `i18n` service is registered. No new export, no new spec +key, no wire-shape change: `AnalyticsResult` carries the same `rows`/`fields` shape as before, +and a kernel with no i18n service configured (or nothing for the requested locale) falls back +to exactly today's authored-label text. + +A future widening of `LOOKUP_TYPES` (#16390) does **not** automatically inherit this: lookup / +master_detail labels resolve through the separate `fetchRecordLabels` capability (a related +RECORD's display name, not a field's authored `options[]`), which this change does not touch. +It does lower the cost of adding translated lookup-record labels later, though — the i18n +service bridge (`plugin.ts`'s `i18nService()` / `buildTranslationBundle()`) is now already +wired into this package and is a `ctx.getService('i18n')` away from reuse. diff --git a/packages/services/service-analytics/src/__tests__/dimension-labels.test.ts b/packages/services/service-analytics/src/__tests__/dimension-labels.test.ts index 01149cc457..51728a888d 100644 --- a/packages/services/service-analytics/src/__tests__/dimension-labels.test.ts +++ b/packages/services/service-analytics/src/__tests__/dimension-labels.test.ts @@ -182,6 +182,76 @@ describe('resolveDimensionLabels', () => { expect(rows).toEqual([{ account: 'Acme Corp', n: 1 }]); }); }); + + // ── #16773 — select option label i18n ─────────────────────────────────── + describe('select option i18n (#16773)', () => { + it('routes a select dimension through translateSelectOptions when the plugin wires one, using the request locale', async () => { + const seen: Array<{ objectName: string; fieldName: string; locale: string | undefined }> = []; + const d = deps({ + translateSelectOptions: (objectName, fieldName, options, locale) => { + seen.push({ objectName, fieldName, locale }); + return options.map((o) => (o.value === 'backlog' ? { ...o, label: '待办' } : o)); + }, + }); + const rows = [{ status: 'backlog', n: 1 }, { status: 'done', n: 2 }]; + await resolveDimensionLabels( + 'task', + [{ name: 'status', field: 'status' }], + rows, + d, + undefined, + { locale: 'zh-CN' } as any, + ); + expect(seen).toEqual([{ objectName: 'task', fieldName: 'status', locale: 'zh-CN' }]); + // Only the translated option changed; an option the translator didn't + // touch (`done`) still renders its AUTHORED label ('Done') — from the + // returned array, not silently dropped. + expect(rows).toEqual([{ status: '待办', n: 1 }, { status: 'Done', n: 2 }]); + }); + + it('falls back to the field\'s own authored label when translateSelectOptions declines (no i18n / nothing for this locale)', async () => { + const d = deps({ translateSelectOptions: () => undefined }); + const rows = [{ status: 'backlog', n: 1 }]; + await resolveDimensionLabels( + 'task', + [{ name: 'status', field: 'status' }], + rows, + d, + undefined, + { locale: 'fr-FR' } as any, + ); + expect(rows).toEqual([{ status: 'Backlog', n: 1 }]); + }); + + it('behaves exactly as before when the plugin declares no translateSelectOptions capability at all', async () => { + const rows = [{ status: 'backlog', n: 1 }]; + await resolveDimensionLabels('task', [{ name: 'status', field: 'status' }], rows, deps() /* no translator */); + expect(rows).toEqual([{ status: 'Backlog', n: 1 }]); + }); + + // The dotted/cross-object CONTROL (#16773): a relationship-path field name + // (`account.region`) never matches a key in the BASE object's own field + // map, so `resolveDimensionLabels` skips it entirely via `if (!meta) + // continue` — select-branch translation included. This is the measured + // reason the dotted arm needs no change: it never reaches this file's + // select branch in the first place, on EITHER strategy, regardless of + // `translateSelectOptions`. + it('a dotted cross-object field name never matches the base object field map — left untouched, translateSelectOptions never consulted', async () => { + let called = false; + const d = deps({ translateSelectOptions: () => { called = true; return undefined; } }); + const rows = [{ region: 'backlog', n: 1 }]; // arbitrary raw value; must survive verbatim + await resolveDimensionLabels( + 'task', + [{ name: 'region', field: 'account.region' }], + rows, + d, + undefined, + { locale: 'zh-CN' } as any, + ); + expect(called).toBe(false); + expect(rows).toEqual([{ region: 'backlog', n: 1 }]); + }); + }); }); describe('formatDateBucket', () => { @@ -301,6 +371,41 @@ describe('AnalyticsService.queryDataset — label resolution (integration)', () }]); }); + it('#16773 — a same-object select dimension renders the LOCALIZED option label end to end when the plugin bridge wires translateSelectOptions', async () => { + const svc = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async () => [ + { status: 'backlog', task_count: 5 }, + { status: 'done', task_count: 3 }, + ], + labelResolver: { + getObjectFields: (obj) => (obj === 'task' ? TASK_FIELDS : undefined), + fetchRecordLabels: async () => new Map(), + translateSelectOptions: (objectName, fieldName, options, locale) => { + if (objectName !== 'task' || fieldName !== 'status' || locale !== 'zh-CN') return undefined; + const zh: Record = { backlog: '待办', in_review: '审核中', done: '完成' }; + return options.map((o) => (typeof o.value === 'string' && zh[o.value] ? { ...o, label: zh[o.value] } : o)); + }, + }, + }); + const statusOnly = DatasetSchema.parse({ + name: 'task_status', + label: 'Task Status', + object: 'task', + dimensions: [{ name: 'status', field: 'status', type: 'string' }], + measures: [{ name: 'task_count', aggregate: 'count' }], + }); + const res = await svc.queryDataset( + statusOnly, + { dimensions: ['status'], measures: ['task_count'] }, + { tenantId: 'org_A', locale: 'zh-CN' } as any, + ); + expect(res.rows).toEqual([ + { status: '待办', task_count: 5 }, + { status: '完成', task_count: 3 }, + ]); + }); + it('enriches measure fields with their display label + format', async () => { const labelledDataset = DatasetSchema.parse({ name: 'sales_metrics', diff --git a/packages/services/service-analytics/src/dimension-labels.ts b/packages/services/service-analytics/src/dimension-labels.ts index 6377e05e5a..497953a21b 100644 --- a/packages/services/service-analytics/src/dimension-labels.ts +++ b/packages/services/service-analytics/src/dimension-labels.ts @@ -18,8 +18,13 @@ * rather than blanking out. Date / number / plain-string dimensions are no-ops. * * The resolution LOGIC lives here (and is unit-tested); the low-level capabilities - * — reading an object's field map and fetching id→label pairs — are injected via - * {@link DimensionLabelDeps} so this module stays free of any engine dependency. + * — reading an object's field map, fetching id→label pairs, and translating a + * select option's label (#16773) — are injected via {@link DimensionLabelDeps} + * so this module stays free of any engine OR i18n dependency: a select option's + * label is looked up in a translation bundle by the SAME translator the + * object-metadata REST endpoint uses (`translateObject`, `@objectstack/spec/system`), + * called from the plugin bridge (`plugin.ts`) — not reimplemented here, so + * there stays exactly ONE copy of "translate a select option label". */ import type { ExecutionContext } from '@objectstack/spec/kernel'; @@ -64,6 +69,34 @@ export interface DimensionLabelDeps { scope?: Record, context?: ExecutionContext, ): Promise>; + /** + * Translate a `select` field's authored `options[]` into `locale` (#16773). + * + * A select option's `label` (`SelectOptionSchema.label`, `packages/spec`) is + * a PLAIN string — never an inline `I18nLabel` map — so its translation, if + * any, lives in an i18n TRANSLATION BUNDLE keyed + * `objects..fields..options.`, the same address + * `translateObject` (`@objectstack/spec/system`) resolves for + * `GET /meta/object/:name` (the object-metadata REST endpoint the console's + * list/kanban/grid renderers already read their translated option labels + * from). This hook is how that SAME translator reaches an analytics + * dimension's option labels too — implemented once, in the plugin bridge + * (`plugin.ts`), by calling `translateObject` itself; nothing here + * reimplements the lookup. + * + * Optional, and `undefined` (no i18n service registered, or nothing for + * this object/field/locale) means "no translation available" — the caller + * then falls back to the field's own authored `options[].label`, exactly + * the pre-existing (locale-blind) behaviour. `locale` is threaded PER CALL, + * never captured when `DimensionLabelDeps` is built, because one instance + * is reused across every request. + */ + translateSelectOptions?( + objectName: string, + fieldName: string, + options: Array<{ value: unknown; label?: string }>, + locale: string | undefined, + ): Array<{ value: unknown; label?: string }> | undefined; } /** @@ -201,6 +234,17 @@ export function withLabelFetchCache(deps: DimensionLabelDeps): DimensionLabelDep } return out; }, + // #16773 — passed straight through: nothing here is id-keyed request + // state to cache, and dropping the capability at this wrapper (as an + // earlier version of this fix did) silently disabled it for every real + // `AnalyticsService.queryDataset` call, which always wraps `labelResolver` + // in this cache (`analytics-service.ts`) — only a hand-rolled `deps()` in + // a unit test bypasses it, which is exactly why that gap did not show up + // until the end-to-end test below was added. + translateSelectOptions: deps.translateSelectOptions + ? (objectName, fieldName, options, locale) => + deps.translateSelectOptions!(objectName, fieldName, options, locale) + : undefined, }; } @@ -310,8 +354,16 @@ export async function resolveDimensionLabels( // ── select: value → option label ────────────────────────────────── if (Array.isArray(meta.options) && meta.options.length > 0) { + // #16773 — the field's own `options[].label` is the AUTHORED (usually + // English) text; consult the i18n translation bundle for this request's + // locale first, via the SAME translator `GET /meta/object/:name` uses, + // and fall back to the authored label when no translation is available + // (no i18n service configured, nothing for this locale, or the option's + // value carries no bundle entry at all). + const translated = deps.translateSelectOptions?.(baseObject, dim.field, meta.options, context?.locale); + const options = translated ?? meta.options; const labelByValue = new Map(); - for (const opt of meta.options) { + for (const opt of options) { if (opt && opt.label != null) labelByValue.set(opt.value, String(opt.label)); } if (labelByValue.size === 0) continue; diff --git a/packages/services/service-analytics/src/plugin.ts b/packages/services/service-analytics/src/plugin.ts index 8074e13470..1c3e1d378b 100644 --- a/packages/services/service-analytics/src/plugin.ts +++ b/packages/services/service-analytics/src/plugin.ts @@ -4,7 +4,8 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import type { Cube, FilterCondition } from '@objectstack/spec/data'; import { AggregationFunction } from '@objectstack/spec/data'; import type { ExecutionContext } from '@objectstack/spec/kernel'; -import type { IAnalyticsService, IDataDriver, IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts'; +import type { IAnalyticsService, IDataDriver, IDataEngine, IObjectQLEngine, II18nService } from '@objectstack/spec/contracts'; +import { translateObject, type ObjectLike, type ObjectFieldLike, type TranslationBundle } from '@objectstack/spec/system'; import { AnalyticsService } from './analytics-service.js'; import type { AnalyticsServiceConfig } from './analytics-service.js'; import type { AnalyticsDriverCapabilities } from './strategies/types.js'; @@ -530,6 +531,38 @@ export class AnalyticsServicePlugin implements Plugin { return svc && typeof svc.getObject === 'function' ? svc : undefined; } catch { return undefined; } }; + + // #16773 — a select option's `label` (`SelectOptionSchema.label`) is a + // PLAIN authored string; its translation, if any, lives in an i18n + // TRANSLATION BUNDLE, not on the field metadata `getObjectFields` reads. + // Resolved lazily, same as `dataEngine` above, so plugin-init order is + // free and a kernel with no i18n service configured degrades to exactly + // today's (locale-blind, authored-label) behaviour. + const i18nService = (): II18nService | undefined => { + try { + const svc = ctx.getService('i18n'); + return svc && typeof svc.getTranslations === 'function' && typeof svc.getLocales === 'function' + ? svc + : undefined; + } catch { return undefined; } + }; + // Mirrors `RestServer.buildTranslationBundle` (`packages/rest`) — this + // package has no dependency on `packages/rest`, so the ~10-line glue that + // turns an `II18nService` into a `TranslationBundle` is rebuilt here + // against the SAME public `II18nService` surface. This is NOT a second + // "translate a select option label" implementation: the actual lookup + // stays exactly one function, `translateObject` below, imported rather + // than reimplemented. + const buildTranslationBundle = (i18n: II18nService): TranslationBundle | undefined => { + const locales = i18n.getLocales(); + if (!locales.length) return undefined; + const bundle: TranslationBundle = {}; + for (const locale of locales) { + const data = i18n.getTranslations(locale); + if (data && typeof data === 'object') (bundle as Record)[locale] = data; + } + return Object.keys(bundle).length ? bundle : undefined; + }; const labelResolver: DimensionLabelDeps = { getObjectFields: (objectName) => dataEngine()?.getObject?.(objectName)?.fields, fetchRecordLabels: async (targetObject, ids, scope, context) => { @@ -592,6 +625,42 @@ export class AnalyticsServicePlugin implements Plugin { } return map; }, + // #16773 — route a select option label through the SAME translator the + // object-metadata REST endpoint uses (`GET /meta/object/:name`, which + // is where the console's list/kanban/grid renderers get theirs), so a + // chart's category/series labels match what those surfaces render for + // the identical field. `undefined` (no i18n service, no locales + // declared, or nothing in the bundle for this locale) leaves the + // caller's authored-label fallback untouched. + translateSelectOptions: (objectName, fieldName, options, locale) => { + if (!locale) return undefined; + const i18n = i18nService(); + if (!i18n) return undefined; + const bundle = buildTranslationBundle(i18n); + if (!bundle) return undefined; + const fallback = typeof i18n.getFallbackLocale === 'function' ? i18n.getFallbackLocale() : undefined; + const defaultLocale = typeof i18n.getDefaultLocale === 'function' ? i18n.getDefaultLocale() : undefined; + // `value` here is `unknown` (an option's stored value, of whatever + // shape the field declares); `ObjectFieldLike.options[].value` narrows + // to `string | number | boolean` (`SelectOptionSchema.value`'s real + // runtime type). The cast is a type-only widening back to what this + // capability's own signature promises — no value is coerced. + const fields: Record = { + [fieldName]: { name: fieldName, options: options as ObjectFieldLike['options'] }, + }; + const doc: ObjectLike = { name: objectName, fields }; + const translated = translateObject(doc, bundle, { + locale, + fallbackChain: typeof fallback === 'string' && fallback.length > 0 ? [fallback] : undefined, + defaultLocale: typeof defaultLocale === 'string' && defaultLocale.length > 0 ? defaultLocale : undefined, + }); + const translatedField = Array.isArray(translated.fields) + ? translated.fields.find((f) => f && f.name === fieldName) + : translated.fields?.[fieldName]; + return Array.isArray(translatedField?.options) + ? (translatedField.options as typeof options) + : undefined; + }, }; // ADR-0037 P3 — draft data preview: resolve the PENDING seed draft's rows From 9b4c0af7f4164d3f6a99c7fb42c80785559995b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 06:30:46 +0000 Subject: [PATCH 2/2] 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 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- .changeset/dataset-select-dimension-option-i18n.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.changeset/dataset-select-dimension-option-i18n.md b/.changeset/dataset-select-dimension-option-i18n.md index cb706c44a6..8f14f660ed 100644 --- a/.changeset/dataset-select-dimension-option-i18n.md +++ b/.changeset/dataset-select-dimension-option-i18n.md @@ -1,7 +1,13 @@ --- -"@objectstack/service-analytics": patch +"@objectstack/service-analytics": minor --- +**The published `DimensionLabelDeps` type (re-exported from this package's `index.ts`) gains +one new optional key, `translateSelectOptions`** — the surface the level is graded against, +per the same "a new key on a published exported type is the mechanical floor for clause ②" +rule #16778 shipped under. Backward compatible (optional, additive, no removed/renamed key, +no wire-shape change), so `minor` rather than `major`. + A dataset's `select`-field dimension now renders its option label in the request's locale on a dataset-backed chart, matching what `GET /meta/object/:name` (and hence the console's list grid) already renders for the identical field.