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
36 changes: 36 additions & 0 deletions .changeset/dataset-select-dimension-option-i18n.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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<string, string> = { 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',
Expand Down
58 changes: 55 additions & 3 deletions packages/services/service-analytics/src/dimension-labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -64,6 +69,34 @@ export interface DimensionLabelDeps {
scope?: Record<string, unknown>,
context?: ExecutionContext,
): Promise<Map<unknown, string>>;
/**
* 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.<object>.fields.<field>.options.<value>`, 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;
}

/**
Expand Down Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -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<unknown, string>();
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;
Expand Down
71 changes: 70 additions & 1 deletion packages/services/service-analytics/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<II18nService>('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<string, unknown>)[locale] = data;
}
return Object.keys(bundle).length ? bundle : undefined;
};
const labelResolver: DimensionLabelDeps = {
getObjectFields: (objectName) => dataEngine()?.getObject?.(objectName)?.fields,
fetchRecordLabels: async (targetObject, ids, scope, context) => {
Expand Down Expand Up @@ -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<string, ObjectFieldLike> = {
[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
Expand Down
Loading