diff --git a/.changeset/dataset-select-dimension-option-i18n.md b/.changeset/dataset-select-dimension-option-i18n.md new file mode 100644 index 0000000000..8f14f660ed --- /dev/null +++ b/.changeset/dataset-select-dimension-option-i18n.md @@ -0,0 +1,36 @@ +--- +"@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. + +`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 b5d20d49e0..7b2d524865 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'; @@ -658,6 +659,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) => { @@ -720,6 +753,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