Skip to content

Commit 0e9371f

Browse files
fix(service-analytics): a picklist-bound select dimension serves its category labels in the request locale (#22213)
Fixes #22178 Clause-②: no A select dimension bound to a shared list (`picklist: 'NAME'`) now serves its chart and report category labels in the request's locale on `POST /api/v1/analytics/dataset/query`. Before this change it served the authored (English) labels under every `Accept-Language`. ## What changed - **`packages/services/service-analytics/src/plugin.ts`, the `translateSelectOptions` bridge.** The synthetic field handed to `translateObject` now carries the dimension field's `picklist`. The value is read from the same served field the caller's `options` came from (`getObject(objectName).fields`). The resolver looks up `picklists.NAME.options.VALUE` only for a field that carries `picklist`, and the synthetic field used to carry `options` alone. A field with inline `options` has no `picklist`, so its synthetic field is built exactly as before. - **`packages/services/service-analytics/src/dimension-labels.ts`, `createOrderLabelResolver.resolveLabels`.** For a select dimension, the sort-key pass now reads the options `translateSelectOptions` returns for the request locale, the same options the display pass reads. The `DimensionLabelDeps.translateSelectOptions` docblock now names the picklist address and both passes. The "Scope" section below explains why this file is in the PR. - One `@objectstack/service-analytics` `patch` changeset. ## Rulings executed (triage comment 6052688863, verbatim; its one angle-bracket placeholder respelled NAME) > - build the synthetic field with the dimension's `picklist`, so `picklists.NAME` translations apply > - inline `options` behave as today > - Pins: `Accept-Language: zh-CN` and `ja-JP` serve translated labels for a picklist-bound dimension; control: inline options are unchanged. > - `Clause-②: no`. Patch changeset for the analytics service. ## Mechanism hypotheses, measured - **H1: confirmed on `6ed0c0f3`.** `plugin.ts:1071-1073` built a synthetic field holding only `name` and `options`. `packages/spec/src/system/i18n-resolver.ts:3063-3068` calls `lookupPicklistOption` only when `typeof def.picklist === 'string'`. - **H2: confirmed.** The engine's `getObject` serves the picklist-bound field with `picklist` next to its resolved `options` (`resolvePicklistFieldsOnto`, the served shape `PicklistServedFieldSchema` declares). The bridge reads `picklist` there, and the caller's signature is unchanged. The first test pins this precondition against a real `ObjectQL` engine. - **H3: confirmed.** The picklist branch runs whenever `picklist` is set. The resolver's own precedence still applies: a field-level entry wins, then the list's entry. A `zh-CN` field-level entry for `logistics` pins it. A field with only inline `options` gets the same synthetic field as before. - **H4: pinned at the service call the door makes.** The door lives in `@objectstack/rest`, which this package does not depend on, so the pins call `AnalyticsService.queryDataset` (the call `POST /api/v1/analytics/dataset/query` makes with the request's ExecutionContext). They run through the plugin's own composition, with a real `ObjectQL` engine on SQLite as `data` and an i18n service as `i18n`, on both strategies (native SQL and ObjectQL aggregate). ## Labels per locale "Before" is `main`'s behaviour, measured by ablation A1 below. "After" is this branch. Fixture: `industry` is bound to picklist `os22178_industry`; `type` has inline options. | locale | dimension | before | after | |:--|:--|:--|:--| | zh-CN | `industry` (picklist) | Education · Energy & Utilities · 物流运输 | 教育 · 能源公用事业 · 物流运输 | | ja-JP | `industry` (picklist) | Education · Energy & Utilities · Logistics | 教育・学術 · エネルギー・公益 · ロジスティクス | | zh-CN | `type` (inline, control) | 正式客户 · 合作伙伴 | 正式客户 · 合作伙伴 | | ja-JP | `type` (inline, control) | 顧客 · パートナー | 顧客 · パートナー | | none | both (control) | authored | authored | `物流运输` in zh-CN comes from the field-level entry: it applied before, and it still beats the list's `物流` after. For each locale, every `industry` label equals what `translateObject` returns for the served object (the object-metadata door's answer for the same field). Ascending `order` (with `limit: 1` keeping the first row): | locale | dimension | before (authored-label order) | after (rendered-label order) | |:--|:--|:--|:--| | zh-CN | `industry` | 教育 · 能源公用事业 · 物流运输 | 教育 · 物流运输 · 能源公用事业 | | zh-CN | `type` | 正式客户 · 合作伙伴 | 合作伙伴 · 正式客户 | | ja-JP | `industry` | 教育・学術 · エネルギー・公益 · ロジスティクス | エネルギー・公益 · ロジスティクス · 教育・学術 | | ja-JP | `type` | 顧客 · パートナー | パートナー · 顧客 | | none | both (control) | authored order | authored order | ## Scope: one step past the dispatch fence, and why The dispatch fenced this card to the `translateSelectOptions` region of `plugin.ts`. Once the picklist-bound dimension's display labels were translated, the sort-key pass (`createOrderLabelResolver`) no longer matched the display for that dimension. An `order` sorted, and a `limit` windowed, by the English label while the row showed the translated one. Before the change both were English, so they agreed. Inline-options dimensions already had this gap, because the sort-key pass never read the locale. The dev agent definition requires fixing a shipped defect that the change makes false, and that rule outranks a dispatch fence. So the fix is here, with its own pins and ablation (A2). It is two lines that mirror the display pass. No open PR touches `packages/services/service-analytics` (the file lists of all 11 open PRs were read). The claim's file surface should add `packages/services/service-analytics/src/dimension-labels.ts` (`createOrderLabelResolver.resolveLabels`, the `DimensionLabelDeps.translateSelectOptions` docblock). Not touched: `packages/spec`, and the `admitObjectRead` region of `plugin.ts` that #22154 holds next. ## Tests (at `59ccbaf04d`) - New: `src/__tests__/picklist-dimension-label-i18n.test.ts`, 26 passed (26). - `pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2`: Test Files 179 passed (179), Tests 4436 passed, 262 skipped (4698). - `pnpm --filter @objectstack/service-analytics typecheck` (`tsc --noEmit`): exit 0. `--listFiles` includes the new test file (176 test files in the program). - Dependency closure `pnpm --filter '@objectstack/service-analytics^...' build`: exit 0. `@objectstack/service-analytics` build: exit 0, `check-dts-emitted` 2/2. - Narrowed lint: `eslint --no-inline-config --format json` over the 3 changed `.ts` files. The JSON reports 3 files, 0 errors, 0 warnings, and none is ignored by `eslint.config.mjs`. The config enables no type-aware linting (no `parserOptions.project`), so this diff cannot change the verdict on any file it does not touch. The full `pnpm lint` run is left to CI. ## Ablations Each ablation used `scripts/ablation-replace.mjs` in wrap mode under the verify lock: the anchor must hit, the on-disk blob change is verified, and the file is restored on exit, INT or TERM. The subject is imported relatively (`../plugin.js`, `../dimension-labels.js`) and resolves to `src/`, so neither leg goes through `dist/`. - **A1: drop the `picklist` spread in `plugin.ts`.** Anchor 1 to 0, blob `b272fe903c6e` to `fa6bf8178e43`. Result: 12 failed, 14 passed. The 8 picklist display pins and the 4 picklist sort pins failed. The inline controls, the no-locale controls and the precondition stayed green. Restored: blob equals HEAD (`b272fe903c6e`), and `git diff HEAD` is empty. - **A2: sort-key pass reads `meta.options` again (`dimension-labels.ts`).** Anchor 1 to 0, blob `228491a908f1` to `7050404c0de1`. Result: 8 failed, 18 passed. Exactly the 8 sort pins failed, each receiving the authored-label order (for example `正式客户, 合作伙伴` where `合作伙伴, 正式客户` was expected). Restored: blob equals HEAD (`228491a908f1`), and `git diff HEAD` is empty. - A fixture guard pins that the rendered-label order is the order `localeCompare` (the executor's comparator) produces, and that it differs from the authored-label order for every locale and dimension. So the sort pins cannot pass vacuously. ## Gates (at `59ccbaf04d`) `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 64 commands from the merge base `6ed0c0f3e` (4 paths). All 64 exited 0. Reconciliation with `--ran` reported: "64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED". The derivation names no i18n family. As extra evidence, `check:i18n`, `check:i18n-walk-parity`, `check:i18n-stale-fill` and `check:i18n-coverage` were also run, and each exited 0 with an OK line. The derivation itself lists what it leaves outside the 64 commands and does not measure: the CI jobs scheduled by these paths (Test Core, Temporal Conformance, Dogfood, Build Core), the CI type-check lanes, and the artifact-roster families. CI runs those. ## Acceptance notes - The HTTP door itself was not booted in-package. The door hands its ExecutionContext to `queryDataset` unchanged, so the pins stand at that call. - The executor compares sort labels with `localeCompare()` and passes no locale, so rendered labels collate in the process's root order, not the request locale's. For example, zh-CN pinyin order would be 教育 · 能源公用事业 · 物流运输, and root order is 教育 · 物流运输 · 能源公用事业. No contract names locale-aware collation. This is noted, not filed. --- _Generated by [Claude Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a87d8be commit 0e9371f

4 files changed

Lines changed: 355 additions & 4 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(service-analytics): a picklist-bound select dimension now renders its category labels in the request's locale
6+
7+
Clause-②: no
8+
9+
A select field that references a shared list (`picklist: '<name>'`) is served with the list's options and with `picklist` kept, and its option labels translate under `picklists.<name>.options.<value>`. The analytics label pass translated a dimension's options through a synthetic field that carried `options` only, so the translator never looked at the `picklists` address. Every chart and report category on such a dimension showed the authored (usually English) labels under every `Accept-Language`, while `GET /api/v1/meta/object/:name` showed the same field translated.
10+
11+
The synthetic field now carries the dimension field's `picklist`. `POST /api/v1/analytics/dataset/query` answers the same labels the object-metadata endpoint serves for that field: a field-level entry (`objects.<object>.fields.<field>.options.<value>`) wins when one exists, and otherwise the list's label applies. A dimension with inline `options` renders exactly as before.
12+
13+
An `order` on a select dimension now sorts by the label the row renders in the request's locale, and a `limit` keeps the first rows in that order. The sort used the authored labels, so under `Accept-Language: zh-CN` or `ja-JP` the order followed the English text, for inline `options` and, once translated, for picklist-bound dimensions too. With no locale, or no translation for it, the order is unchanged.
14+
15+
Nothing you author changes: no key, export or parameter is added or removed.
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22178] A picklist-bound select dimension renders its category labels in
5+
* the request's locale, through the plugin's own `translateSelectOptions`
6+
* bridge.
7+
*
8+
* ## The shape this closes
9+
*
10+
* A select field may author `picklist: '<name>'` in place of inline `options`
11+
* (`FieldSchema.picklist`). The registry serves it with the list's options
12+
* resolved onto it AND `picklist` kept (`PicklistServedFieldSchema`), and its
13+
* option labels translate under `picklists.<name>.options.<value>`, which
14+
* every referencing field inherits. `translateObject` consults that address
15+
* only for a field that carries `picklist`; the plugin built its synthetic
16+
* field from `options` alone, so a picklist-bound dimension rendered its
17+
* authored (English) labels under every `Accept-Language`, while
18+
* `GET /meta/object/:name` relabelled the identical field. Measured on
19+
* `POST /api/v1/analytics/dataset/query` in hotcrm: `zh-CN` and `ja-JP`
20+
* answered English for the picklist-bound `industry`, and translated labels
21+
* for the inline-options `type` beside it.
22+
*
23+
* ## What these pins hold
24+
*
25+
* - **The precondition the fix reads.** The real engine serves the
26+
* picklist-bound field with `picklist` beside its resolved `options`, and
27+
* the inline field with no `picklist`.
28+
* - **The defect.** Under `zh-CN` and `ja-JP` the picklist-bound dimension
29+
* renders the list's translated labels, on both strategies. A field-level
30+
* entry for one value is the more specific address and wins over the
31+
* list's, exactly as on the object-metadata door: every label equals what
32+
* `translateObject` gives the served object for the same locale.
33+
* - **The control.** The inline-options dimension renders its field-level
34+
* translations, exactly as before; with no locale both dimensions render
35+
* their authored labels.
36+
* - **The sort-key pass.** Once the picklist-bound dimension renders
37+
* translated labels, an ascending `order` on it must sort, and a `limit`
38+
* window it, by those labels rather than the authored ones it sorted by
39+
* before; the same holds for the inline dimension, whose sort keys were
40+
* never translated either. With no locale the order is the authored one.
41+
*
42+
* `AnalyticsService.queryDataset` is what `POST /api/v1/analytics/dataset/query`
43+
* calls, with the request's ExecutionContext (its `locale` read from
44+
* `Accept-Language`). The door lives in `@objectstack/rest`, which this package
45+
* does not depend on, so the pins stand at the service call it makes, over the
46+
* plugin's real composition: a real `ObjectQL` engine as `data` and an i18n
47+
* service as `i18n`.
48+
*/
49+
50+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
51+
import { ObjectQL } from '@objectstack/objectql';
52+
import { SqlDriver } from '@objectstack/driver-sql';
53+
import { DatasetSchema } from '@objectstack/spec/ui';
54+
import { translateObject, type TranslationBundle } from '@objectstack/spec/system';
55+
import type { AnalyticsService } from '../analytics-service.js';
56+
import { AnalyticsServicePlugin } from '../plugin.js';
57+
58+
const OBJECT = 'os22178_account';
59+
const PICKLIST = 'os22178_industry';
60+
61+
const INDUSTRY = {
62+
name: PICKLIST,
63+
label: 'Industry',
64+
options: [
65+
{ label: 'Education', value: 'education' },
66+
{ label: 'Energy & Utilities', value: 'energy' },
67+
{ label: 'Logistics', value: 'logistics' },
68+
],
69+
};
70+
71+
const ACCOUNT = {
72+
name: OBJECT,
73+
label: 'Account',
74+
fields: {
75+
name: { name: 'name', label: 'Name', type: 'text' as const },
76+
industry: { name: 'industry', label: 'Industry', type: 'select' as const, picklist: PICKLIST },
77+
type: {
78+
name: 'type',
79+
label: 'Type',
80+
type: 'select' as const,
81+
options: [
82+
{ label: 'Customer', value: 'customer' },
83+
{ label: 'Partner', value: 'partner' },
84+
],
85+
},
86+
},
87+
};
88+
89+
const ROWS = [
90+
{ name: 'a1', industry: 'education', type: 'customer' },
91+
{ name: 'a2', industry: 'education', type: 'partner' },
92+
{ name: 'a3', industry: 'energy', type: 'customer' },
93+
{ name: 'a4', industry: 'logistics', type: 'customer' },
94+
] as const;
95+
96+
/**
97+
* The bundle an i18n service holds: the list's labels under `picklists`, the
98+
* inline field's under `objects`. `zh-CN` also carries a FIELD-level entry for
99+
* one value of the picklist-bound field — the more specific address.
100+
*/
101+
const TRANSLATIONS: Record<string, Record<string, unknown>> = {
102+
en: {},
103+
'zh-CN': {
104+
picklists: { [PICKLIST]: { label: '行业', options: { education: '教育', energy: '能源公用事业', logistics: '物流' } } },
105+
objects: {
106+
[OBJECT]: {
107+
fields: {
108+
industry: { options: { logistics: '物流运输' } },
109+
type: { options: { customer: '正式客户', partner: '合作伙伴' } },
110+
},
111+
},
112+
},
113+
},
114+
'ja-JP': {
115+
picklists: { [PICKLIST]: { label: '業種', options: { education: '教育・学術', energy: 'エネルギー・公益', logistics: 'ロジスティクス' } } },
116+
objects: { [OBJECT]: { fields: { type: { options: { customer: '顧客', partner: 'パートナー' } } } } },
117+
},
118+
};
119+
120+
const i18n = {
121+
getLocales: () => Object.keys(TRANSLATIONS),
122+
getTranslations: (locale: string) => TRANSLATIONS[locale] ?? {},
123+
getDefaultLocale: () => 'en',
124+
getFallbackLocale: () => 'en',
125+
t: (key: string) => key,
126+
loadTranslations: () => {},
127+
};
128+
129+
/** Count per rendered label, from the rows the service answered. */
130+
const EXPECTED: Record<string, { industry: Record<string, number>; type: Record<string, number> }> = {
131+
'zh-CN': {
132+
industry: { 教育: 2, 能源公用事业: 1, 物流运输: 1 },
133+
type: { 正式客户: 3, 合作伙伴: 1 },
134+
},
135+
'ja-JP': {
136+
industry: { '教育・学術': 2, 'エネルギー・公益': 1, ロジスティクス: 1 },
137+
type: { 顧客: 3, パートナー: 1 },
138+
},
139+
};
140+
141+
const AUTHORED = {
142+
industry: { Education: 2, 'Energy & Utilities': 1, Logistics: 1 },
143+
type: { Customer: 3, Partner: 1 },
144+
};
145+
146+
/**
147+
* Each dimension's rendered labels in ascending order of the rendered label
148+
* (the executor compares sort labels with `localeCompare`), and — the vacuity
149+
* guard — the same labels in ascending order of their AUTHORED labels, which
150+
* is what an `order` on a select dimension sorted by before. The two differ
151+
* for every locale and dimension here, so a sort-key pass that still read the
152+
* authored label cannot satisfy a pin on the first.
153+
*/
154+
const ORDERED = {
155+
'zh-CN': {
156+
industry: { byRendered: ['教育', '物流运输', '能源公用事业'], byAuthored: ['教育', '能源公用事业', '物流运输'] },
157+
type: { byRendered: ['合作伙伴', '正式客户'], byAuthored: ['正式客户', '合作伙伴'] },
158+
},
159+
'ja-JP': {
160+
industry: { byRendered: ['エネルギー・公益', 'ロジスティクス', '教育・学術'], byAuthored: ['教育・学術', 'エネルギー・公益', 'ロジスティクス'] },
161+
type: { byRendered: ['パートナー', '顧客'], byAuthored: ['顧客', 'パートナー'] },
162+
},
163+
} as const;
164+
165+
const AUTHORED_ORDER = { industry: ['Education', 'Energy & Utilities', 'Logistics'], type: ['Customer', 'Partner'] } as const;
166+
167+
const DATASET = DatasetSchema.parse({
168+
name: 'os22178_account_metrics',
169+
label: 'Account metrics',
170+
object: OBJECT,
171+
dimensions: [
172+
{ name: 'industry', field: 'industry', type: 'string' },
173+
{ name: 'type', field: 'type', type: 'string' },
174+
],
175+
measures: [{ name: 'account_count', aggregate: 'count' }],
176+
});
177+
178+
const FACES = ['native', 'objectql'] as const;
179+
type Face = (typeof FACES)[number];
180+
181+
const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } };
182+
183+
describe('[#22178] a picklist-bound select dimension renders its labels in the request locale', () => {
184+
let engine: ObjectQL;
185+
const services: Partial<Record<Face, AnalyticsService>> = {};
186+
187+
/** `{ label: count }` for one dimension, as the dataset door would answer it. */
188+
const labelsOf = async (face: Face, dimension: 'industry' | 'type', locale?: string) => {
189+
const res = await services[face]!.queryDataset(
190+
DATASET,
191+
{ dimensions: [dimension], measures: ['account_count'] },
192+
(locale ? { locale } : {}) as never,
193+
);
194+
const out: Record<string, number> = {};
195+
for (const row of res.rows) out[String(row[dimension])] = Number(row.account_count);
196+
return out;
197+
};
198+
199+
/** The rendered labels in the order the service answered an ascending `order` on the dimension. */
200+
const orderedLabelsOf = async (face: Face, dimension: 'industry' | 'type', locale?: string, limit?: number) => {
201+
const res = await services[face]!.queryDataset(
202+
DATASET,
203+
{ dimensions: [dimension], measures: ['account_count'], order: { [dimension]: 'asc' as const }, ...(limit ? { limit } : {}) },
204+
(locale ? { locale } : {}) as never,
205+
);
206+
return res.rows.map((row) => String(row[dimension]));
207+
};
208+
209+
beforeAll(async () => {
210+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as never);
211+
engine = new ObjectQL({ logger: quiet } as never);
212+
engine.registerDriver(driver, true);
213+
await engine.init();
214+
engine.registerApp({ id: 'com.test.os22178', name: 'os22178', picklists: [INDUSTRY], objects: [ACCOUNT] } as never);
215+
await engine.syncSchemas();
216+
for (const row of ROWS) await engine.insert(OBJECT, { ...row } as never);
217+
218+
for (const [face, caps] of [
219+
['native', undefined],
220+
['objectql', () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false })],
221+
] as const) {
222+
const registered: Record<string, unknown> = {};
223+
await new AnalyticsServicePlugin({ ...(caps ? { queryCapabilities: caps } : {}) } as never).init({
224+
getService: (name: string) => (name === 'data' ? engine : name === 'i18n' ? i18n : registered[name]),
225+
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
226+
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
227+
hook: () => {},
228+
logger: quiet,
229+
} as never);
230+
services[face] = registered.analytics as AnalyticsService;
231+
}
232+
});
233+
234+
afterAll(async () => {
235+
try { await engine?.destroy(); } catch { /* noop */ }
236+
});
237+
238+
it('the engine serves the picklist-bound field with `picklist` beside its resolved options, and the inline field with none', () => {
239+
const fields = engine.getObject(OBJECT)!.fields as Record<string, { picklist?: unknown; options?: Array<{ value: unknown }> }>;
240+
expect(fields.industry.picklist).toBe(PICKLIST);
241+
expect(fields.industry.options!.map((o) => o.value)).toEqual(['education', 'energy', 'logistics']);
242+
expect(fields.type.picklist).toBeUndefined();
243+
expect(fields.type.options!.map((o) => o.value)).toEqual(['customer', 'partner']);
244+
});
245+
246+
it('the ordering fixture discriminates: rendered-label order is the comparator\'s order and differs from authored-label order', () => {
247+
for (const locale of ['zh-CN', 'ja-JP'] as const) {
248+
for (const dimension of ['industry', 'type'] as const) {
249+
const { byRendered, byAuthored } = ORDERED[locale][dimension];
250+
expect([...byRendered].sort((a, b) => a.localeCompare(b)), `${locale} ${dimension}`).toEqual(byRendered);
251+
expect(byAuthored, `${locale} ${dimension}`).not.toEqual(byRendered);
252+
expect([...byAuthored].sort(), `${locale} ${dimension}`).toEqual([...byRendered].sort());
253+
}
254+
}
255+
});
256+
257+
for (const face of FACES) {
258+
describe(`${face} strategy`, () => {
259+
for (const locale of ['zh-CN', 'ja-JP'] as const) {
260+
it(`${locale}: the picklist-bound dimension renders the list's translated labels`, async () => {
261+
expect(await labelsOf(face, 'industry', locale)).toEqual(EXPECTED[locale].industry);
262+
});
263+
264+
it(`${locale}: every picklist-bound label equals the object-metadata door's label for the same field`, async () => {
265+
const bundle = TRANSLATIONS as unknown as TranslationBundle;
266+
const served = translateObject(engine.getObject(OBJECT)! as never, bundle, {
267+
locale,
268+
fallbackChain: ['en'],
269+
defaultLocale: 'en',
270+
}) as { fields: Record<string, { options: Array<{ value: string; label: string }> }> };
271+
const counts: Record<string, number> = { education: 2, energy: 1, logistics: 1 };
272+
const fromMetaDoor: Record<string, number> = {};
273+
for (const opt of served.fields.industry.options) fromMetaDoor[opt.label] = counts[opt.value];
274+
expect(await labelsOf(face, 'industry', locale)).toEqual(fromMetaDoor);
275+
});
276+
277+
it(`${locale}: control — the inline-options dimension renders its field-level translations, as before`, async () => {
278+
expect(await labelsOf(face, 'type', locale)).toEqual(EXPECTED[locale].type);
279+
});
280+
}
281+
282+
it('control — with no locale, both dimensions render their authored labels', async () => {
283+
expect(await labelsOf(face, 'industry')).toEqual(AUTHORED.industry);
284+
expect(await labelsOf(face, 'type')).toEqual(AUTHORED.type);
285+
});
286+
287+
// The sort-key pass reads the same translated options as the display
288+
// pass, so an `order` sorts, and a `limit` windows, by the label the
289+
// row renders — for the picklist-bound and the inline dimension alike.
290+
for (const locale of ['zh-CN', 'ja-JP'] as const) {
291+
for (const dimension of ['industry', 'type'] as const) {
292+
it(`${locale}: an ascending order on ${dimension} sorts and windows by the rendered label`, async () => {
293+
const { byRendered } = ORDERED[locale][dimension];
294+
expect(await orderedLabelsOf(face, dimension, locale)).toEqual(byRendered);
295+
expect(await orderedLabelsOf(face, dimension, locale, 1)).toEqual([byRendered[0]]);
296+
});
297+
}
298+
}
299+
300+
it('control — with no locale, an ascending order sorts by the authored label', async () => {
301+
expect(await orderedLabelsOf(face, 'industry')).toEqual(AUTHORED_ORDER.industry);
302+
expect(await orderedLabelsOf(face, 'type')).toEqual(AUTHORED_ORDER.type);
303+
});
304+
});
305+
}
306+
});

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,12 @@ export interface DimensionLabelDeps {
9696
* `translateObject` (`@objectstack/spec/system`) resolves for
9797
* `GET /meta/object/:name` (the object-metadata REST endpoint the console's
9898
* list/kanban/grid renderers already read their translated option labels
99-
* from). This hook is how that SAME translator reaches an analytics
100-
* dimension's option labels too — implemented once, in the plugin bridge
99+
* from). A picklist-bound field (`picklist: '<name>'`) also inherits its
100+
* list's labels, `picklists.<name>.options.<value>`, where no field-level
101+
* entry exists (#22178) — the bridge hands the translator the field's
102+
* `picklist`, as that endpoint does. This hook is how that SAME translator
103+
* reaches an analytics dimension's option labels too, for the display pass
104+
* and the sort-key pass alike — implemented once, in the plugin bridge
101105
* (`plugin.ts`), by calling `translateObject` itself; nothing here
102106
* reimplements the lookup.
103107
*
@@ -216,8 +220,16 @@ export function createOrderLabelResolver(
216220
const meta = metaFor(dimension);
217221
if (!meta) return undefined;
218222
if (Array.isArray(meta.options) && meta.options.length > 0) {
223+
// [#22178] Sort by the label the display pass renders: the options
224+
// `translateSelectOptions` answers for this request's locale, else the
225+
// authored ones, exactly as `resolveDimensionLabels` reads them below.
226+
// Sorting by the authored label while rendering the translated one
227+
// ordered a localized grid, and windowed its `limit`, by text the user
228+
// never sees. `meta` was found through this dimension, so it is known.
229+
const field = dimByName.get(dimension)!.field;
230+
const translated = deps.translateSelectOptions?.(baseObject, field, meta.options, context?.locale);
219231
const labelByValue = new Map<unknown, string>();
220-
for (const opt of meta.options) {
232+
for (const opt of translated ?? meta.options) {
221233
if (opt && opt.label != null) labelByValue.set(opt.value, String(opt.label));
222234
}
223235
return labelByValue;

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1068,8 +1068,26 @@ export class AnalyticsServicePlugin implements Plugin {
10681068
// to `string | number | boolean` (`SelectOptionSchema.value`'s real
10691069
// runtime type). The cast is a type-only widening back to what this
10701070
// capability's own signature promises — no value is coerced.
1071+
//
1072+
// [#22178] A picklist-bound field (`FieldSchema.picklist`) is served
1073+
// with its list's options resolved onto it AND `picklist` kept
1074+
// (`PicklistServedFieldSchema`), and its option labels translate
1075+
// under `picklists.<name>.options.<value>`, which every referencing
1076+
// field inherits. `translateObject` takes that branch only for a
1077+
// field that carries `picklist`, so a synthetic field holding
1078+
// `options` alone served the authored labels in every locale while
1079+
// `GET /meta/object/:name` relabelled the same field. `picklist` is
1080+
// read from the same served field the caller's `options` came from
1081+
// (`getObjectFields` above reads `getObject(objectName).fields`). A
1082+
// field with inline `options` carries no `picklist`, so its
1083+
// synthetic field is exactly what it was before.
1084+
const picklist = dataEngine()?.getObject?.(objectName)?.fields?.[fieldName]?.picklist;
10711085
const fields: Record<string, ObjectFieldLike> = {
1072-
[fieldName]: { name: fieldName, options: options as ObjectFieldLike['options'] },
1086+
[fieldName]: {
1087+
name: fieldName,
1088+
options: options as ObjectFieldLike['options'],
1089+
...(typeof picklist === 'string' && picklist.length > 0 ? { picklist } : {}),
1090+
},
10731091
};
10741092
const doc: ObjectLike = { name: objectName, fields };
10751093
const translated = translateObject(doc, bundle, {

0 commit comments

Comments
 (0)