From 4d58fc60551b8156e05c91f793d9f7bf2d82696e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:44:31 +0000 Subject: [PATCH 1/3] fix(plugin-approvals): serve the flow, step and object labels in the reader's locale The approvals door served the request's open-time snapshot of the flow and step labels to every reader, and the object's schema label on the inbox row. Pick flows.FLOW.label, flows.FLOW.approvals.NODE.label and objects.OBJECT.label through the i18n service in ExecutionContext.locale at read time, falling back to the stored snapshot. The stored columns are not touched. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK --- .../plugin-approvals/src/approval-service.ts | 63 +++- .../plugin-approvals/src/approvals-plugin.ts | 31 +- ...-locale-display-labels.integration.test.ts | 303 ++++++++++++++++++ .../src/reader-locale-labels.test.ts | 181 +++++++++++ .../src/reader-locale-labels.ts | 189 +++++++++++ 5 files changed, 755 insertions(+), 12 deletions(-) create mode 100644 packages/plugins/plugin-approvals/src/reader-locale-display-labels.integration.test.ts create mode 100644 packages/plugins/plugin-approvals/src/reader-locale-labels.test.ts create mode 100644 packages/plugins/plugin-approvals/src/reader-locale-labels.ts diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index b3abe2b75a6..06899aa721b 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -93,6 +93,12 @@ import { type ActingCaller, } from './approver-address.js'; import { labelOrMachineName, requestDisplayLabels, slateLabelOf } from './display-labels.js'; +import { + localizeRequestLabels, + readerLabelsFor, + type ReaderLabelI18n, + type ReaderLabels, +} from './reader-locale-labels.js'; import type { SlotLabelSource } from './display-label-backfill.js'; import { redactSnapshot, @@ -1429,6 +1435,17 @@ export interface ApprovalServiceOptions { * `translation` metadata wins over the built-in sentence. */ messageTranslator?: () => ValidationMessageTranslator | undefined; + /** + * [#24863] The deployment's `i18n` service, through which the approvals door + * serves a request's flow, step and object labels in the READER's locale + * (`reader-locale-labels.ts`). Resolved LAZILY per read, for the reason + * {@link ApprovalServiceOptions.messageTranslator} is: the provider is + * another plugin's and may start after this one. + * + * Absent (or resolving to `undefined`) serves the labels as stored, the + * authored text the request snapshotted when it opened. + */ + i18nServiceSource?: () => ReaderLabelI18n | undefined; } /** @@ -1528,6 +1545,8 @@ export class ApprovalService implements IApprovalService { private objectReadAdmission?: ObjectReadAdmissionSource; /** [#11993] Lazily-resolved deployment i18n lookup for refusal copy. */ private messageTranslator?: () => ValidationMessageTranslator | undefined; + /** [#24863] Lazily-resolved `i18n` service for the reader-locale display labels. */ + private i18nServiceSource?: () => ReaderLabelI18n | undefined; /** * [#8652] The HOST-named object set for the record-reader visibility tier * (the constructor option). EMPTY is the default, and the tier is then off @@ -1556,6 +1575,7 @@ export class ApprovalService implements IApprovalService { this.tenancyPosture = opts.tenancyPosture; this.fieldVisibility = opts.fieldVisibility; this.messageTranslator = opts.messageTranslator; + this.i18nServiceSource = opts.i18nServiceSource; this.recordReaderVisibleObjects = new Set( (Array.isArray(opts.recordReaderVisibleObjects) ? opts.recordReaderVisibleObjects : []) .map((n) => String(n ?? '').trim()) @@ -8164,10 +8184,38 @@ export class ApprovalService implements IApprovalService { const served = await this.servedSubjectFields(list, context); this.redactPayloads(list, served); await this.enrichRows(list, served, context); + const labels = this.readerLabels(context); + if (labels) for (const row of list) localizeRequestLabels(row, labels); this.attachViewers(list, context, caller); return list; } + /** + * [#24863] The labels this read serves in the READER's locale + * (`ExecutionContext.locale`), picked through the deployment's `i18n` + * service from `flows..label`, `flows..approvals..label` + * and `objects..label`, or `undefined` when the read can pick none + * (no locale, no service). The one spelling of the pick, the fallback and + * the keys is `reader-locale-labels.ts`. + * + * A read, never a write: the stored `process_label` / `current_step_label` + * stay the authored snapshot, and a label nothing is picked for is served as + * the row already carries it. Asked on every door that serves a request to + * a reader: the list ({@link listRequests}: the inbox and the record's + * approval panel), and the get and a decision's echo ({@link loadRequest}), + * whose step progress ({@link attachFlowSteps}) names its steps the same way. + */ + private readerLabels(context: ExecutionContext): ReaderLabels | undefined { + let service: ReaderLabelI18n | undefined; + try { + service = this.i18nServiceSource?.(); + } catch { + // The i18n service is optional and late-bound: the stored labels stand. + return undefined; + } + return readerLabelsFor(service, context?.locale); + } + async countRequests( filter: Parameters[0], context: ExecutionContext, @@ -8278,7 +8326,9 @@ export class ApprovalService implements IApprovalService { const served = await this.servedSubjectFields([row], context); this.redactPayloads([row], served); await this.enrichRows([row], served, context); - await this.attachFlowSteps(row); + const labels = this.readerLabels(context); + if (labels) localizeRequestLabels(row, labels); + await this.attachFlowSteps(row, labels); await this.attachDecisionProgress(row, raw); this.attachViewers([row], context, caller); return row; @@ -8387,8 +8437,13 @@ export class ApprovalService implements IApprovalService { * preferring `approve`/`true` edges, so the result is the flow's main * approval trunk; conditional side-steps show as part of the potential * path. Display-only and best-effort. + * + * [#24863] A step is named the way the request's own step is: in the + * reader's locale when `labels` picks its `flows..approvals..label`, + * else by the node's authored label. So the progress strip and the request's + * `current_step_label` name the current step alike. */ - private async attachFlowSteps(row: ApprovalRequestRow): Promise { + private async attachFlowSteps(row: ApprovalRequestRow, labels: ReaderLabels | undefined): Promise { try { const flowName = row.process_name?.startsWith('flow:') ? row.process_name.slice(5) : undefined; if (!flowName || typeof this.automation?.getFlow !== 'function') return; @@ -8400,7 +8455,9 @@ export class ApprovalService implements IApprovalService { let cur: any = flow.nodes.find((n: any) => n.type === 'start'); while (cur && !seen.has(cur.id)) { seen.add(cur.id); - if (cur.type === 'approval') steps.push({ id: cur.id, label: cur.label || cur.id }); + if (cur.type === 'approval') { + steps.push({ id: cur.id, label: labels?.stepLabel(flowName, cur.id) ?? (cur.label || cur.id) }); + } const out = (flow.edges ?? []).filter((e: any) => e.source === cur.id); if (!out.length) break; const pick = out.find((e: any) => e.label === 'approve') diff --git a/packages/plugins/plugin-approvals/src/approvals-plugin.ts b/packages/plugins/plugin-approvals/src/approvals-plugin.ts index 8d994b321b1..8435b6453ad 100644 --- a/packages/plugins/plugin-approvals/src/approvals-plugin.ts +++ b/packages/plugins/plugin-approvals/src/approvals-plugin.ts @@ -216,6 +216,20 @@ export class ApprovalsServicePlugin implements Plugin { } this.engine = engine; + // The deployment's `i18n` service, read LAZILY at question time: it is + // contributed by another plugin (ADR-0029 D8) and may start after this + // one, so a reading taken here would pin its absence for the life of the + // process. `getService` THROWS on an empty slot. The one read both i18n + // consumers below ask, so they cannot disagree about which service exists. + const i18nService = (): II18nService | undefined => { + try { + const i18n = ctx.getService('i18n'); + return typeof i18n?.t === 'function' ? i18n : undefined; + } catch { + return undefined; + } + }; + this.service = new ApprovalService({ engine: engine as ApprovalEngine, logger: ctx.logger, @@ -248,16 +262,15 @@ export class ApprovalsServicePlugin implements Plugin { // `translation` for `errors.approval_recall_not_submitter` wins — the // override address the catalog documents, made real for this emitter. messageTranslator: () => { - try { - const i18n = ctx.getService('i18n'); - const t = i18n?.t; - if (typeof t !== 'function') return undefined; - return (key: string, locale: string, params?: Record) => - t.call(i18n, key, locale, params); - } catch { - return undefined; - } + const i18n = i18nService(); + if (!i18n) return undefined; + return (key: string, locale: string, params?: Record) => + i18n.t(key, locale, params); }, + // [#24863] The same service, for the approvals door's flow, step and + // object labels in the reader's locale (`reader-locale-labels.ts`). + // Without one, the door serves the labels as stored. + i18nServiceSource: i18nService, }); // [#10749] Field-visibility authority for payload-snapshot redaction. diff --git a/packages/plugins/plugin-approvals/src/reader-locale-display-labels.integration.test.ts b/packages/plugins/plugin-approvals/src/reader-locale-display-labels.integration.test.ts new file mode 100644 index 00000000000..2583c61b70a --- /dev/null +++ b/packages/plugins/plugin-approvals/src/reader-locale-display-labels.integration.test.ts @@ -0,0 +1,303 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24863] The approvals door names a request's flow, its step and its record's + * object in the READER's language. + * + * ## The defect + * + * A request snapshots the flow's and the Approval node's authored labels when + * it opens, and the approvals door served that snapshot to every reader. A + * zh-CN, ja-JP or es-ES user saw the authoring language on the inbox, the + * record's approval panel and the request page, although the app's language + * pack carried `flows..label` and `flows..approvals..label` + * for exactly those strings. The inbox row's `object_label` was the object's + * schema label, untranslated. + * + * ## The rig + * + * A real `ObjectQL` over `SqlDriver` (better-sqlite3, in-memory, real DDL), the + * real `AutomationEngine` running the flow, so the request is opened by the + * real approval node with the flow name, the flow label and the node label the + * engine hands it, and the real `ApprovalsServicePlugin`, started over a kernel + * whose `i18n` service is the real in-memory implementation + * (`createMemoryI18n`, `@objectstack/core`) loaded with a zh-CN pack the way + * `AppPlugin` loads one. The pack is parsed by `TranslationDataSchema` first, + * so every key it carries is one the translation contract declares. + * + * Each reader's locale is `ExecutionContext.locale`, the value the REST door's + * shared assembler resolves from `Accept-Language` (or the `localization` + * settings) before it calls the service. + * + * ## What it pins + * + * - a zh-CN reader is served the zh-CN flow label, step label (under both of + * its names) and object label on the record's approval panel (the list + * filtered to one record), the inbox (the list filtered to an approver), + * the get, and the get's step progress; + * - a decision's echo is served in the decider's locale; + * - a reader in a locale no pack holds (ja-JP) is served the stored snapshot; + * - an en reader and a reader with no locale are served exactly what the door + * served before; + * - a label the pack does not carry is served as stored beside one it does; + * - the stored row is never rewritten: it keeps the authored text it opened + * with. + * + * ⚠️ Direction, predicted before running: every zh-CN pin is RED on the unfixed + * door (it served the snapshot whatever the locale); every "as stored" pin is + * green on both sides on purpose, fencing the fallback. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { AutomationEngine } from '@objectstack/service-automation'; +import { createMemoryI18n } from '@objectstack/core'; +import { TranslationDataSchema } from '@objectstack/spec/system'; +import type { EngineQueryOptions } from '@objectstack/spec/data'; +import type { ApprovalService } from './approval-service.js'; +import { ApprovalsServicePlugin } from './approvals-plugin.js'; + +const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; +const reader = (userId: string, locale?: string) => + ({ userId, positions: [], permissions: [], ...(locale ? { locale } : {}) }) as any; +const silent = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as any; + +const SUBMITTER = 'u_sub'; +const MANAGER = 'u_mgr'; +const DIRECTOR = 'u_dir'; +const OBJECT = 'crm_account'; + +const sysUser = { + name: 'sys_user', + label: 'User', + fields: { + id: { name: 'id', label: 'Id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', label: 'Name', type: 'text' as const }, + email: { name: 'email', label: 'Email', type: 'text' as const }, + }, +}; +const account = { + name: OBJECT, + label: 'Account', + fields: { + id: { name: 'id', label: 'Id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', label: 'Name', type: 'text' as const }, + }, +}; + +/** Two approval steps; the pack translates the first step and not the second. */ +const FLOW = { + name: 'account_approval', label: 'Account Approval', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'manager_review', type: 'approval', label: 'Manager Review', config: { approvers: [{ type: 'user', value: MANAGER }] } }, + { id: 'director_review', type: 'approval', label: 'Director Review', config: { approvers: [{ type: 'user', value: DIRECTOR }] } }, + { id: 'ok', type: 'end', label: 'Approved' }, + { id: 'no', type: 'end', label: 'Rejected' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'manager_review' }, + { id: 'e2', source: 'manager_review', target: 'director_review', label: 'approve' }, + { id: 'e3', source: 'manager_review', target: 'no', label: 'reject' }, + { id: 'e4', source: 'director_review', target: 'ok', label: 'approve' }, + { id: 'e5', source: 'director_review', target: 'no', label: 'reject' }, + ], +}; + +/** The app's zh-CN language pack: the flow, its first step, and the subject object. */ +const ZH_PACK = { + objects: { [OBJECT]: { label: '客户' } }, + flows: { + account_approval: { + label: '客户审批', + approvals: { manager_review: { label: '经理审核' } }, + }, + }, +}; + +/** What the door served before: the authored labels, as the request stored them. */ +const AS_STORED = { + process_label: 'Account Approval', + step_label: 'Manager Review', + current_step_label: 'Manager Review', + object_label: 'Account', +}; +const IN_ZH = { + process_label: '客户审批', + step_label: '经理审核', + current_step_label: '经理审核', + object_label: '客户', +}; + +const labelsOf = (row: any) => ({ + process_label: row?.process_label, + step_label: row?.step_label, + current_step_label: row?.current_step_label, + object_label: row?.object_label, +}); + +/** The plugin's own object definitions, as its manifest registers them. */ +async function packageObjects(): Promise { + let manifest: any; + const ctx: any = { + getService: (name: string) => { + if (name === 'manifest') return { register: (m: any) => { manifest = m; } }; + throw new Error(`no service '${name}'`); + }, + logger: silent, + }; + await new ApprovalsServicePlugin().init(ctx); + return manifest.objects; +} + +describe('[#24863] the approvals door serves a request\'s labels in the reader\'s locale', () => { + let engine: ObjectQL; + let automation: AutomationEngine; + let svc: ApprovalService; + let seq = 0; + + /** Start the flow on a fresh account as the submitter; answer the request it paused on. */ + const submit = async (): Promise => { + const id = `acc_${++seq}`; + const record = { id, name: `Acme ${seq}` }; + await engine.insert(OBJECT, record, { context: SYSTEM } as any); + const run = await automation.execute(FLOW.name, { object: OBJECT, record, userId: SUBMITTER } as any); + expect(run.status, 'the run paused on its first approval step').toBe('paused'); + const rows: any[] = await engine.find('sys_approval_request', { + where: { record_id: id, status: 'pending' }, context: SYSTEM, + } satisfies EngineQueryOptions); + expect(rows).toHaveLength(1); + return rows[0]; + }; + const stored = async (requestId: string): Promise => + engine.findOne('sys_approval_request', { where: { id: requestId } }, { context: SYSTEM }); + + beforeAll(async () => { + // The pack a deployment would ship is one the contract accepts, key for key. + const parsed = TranslationDataSchema.safeParse(ZH_PACK) as { success: boolean; error?: unknown }; + expect(parsed.success, JSON.stringify(parsed.error)).toBe(true); + + engine = new ObjectQL(); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true, + }), true); + await engine.init(); + for (const def of [sysUser, account, ...(await packageObjects())]) { + engine.registry.registerObject(def as any, 'approvals-reader-locale-test', 'approvals-reader-locale-test'); + } + await engine.syncSchemas(); + for (const [id, name] of [[SUBMITTER, 'Sam Submitter'], [MANAGER, 'Mona Manager'], [DIRECTOR, 'Dina Director']]) { + await engine.insert('sys_user', { id, name, email: `${id}@example.com` }, { context: SYSTEM } as any); + } + + automation = new AutomationEngine(silent); + const i18n = createMemoryI18n(); + i18n.loadTranslations('zh-CN', ZH_PACK); + + const services: Record = { objectql: engine, automation, i18n }; + const ctx: any = { + getService: (name: string) => { + if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`); + return services[name]; + }, + registerService: (name: string, service: unknown) => { services[name] = service; }, + hook: () => {}, + logger: silent, + }; + // The real plugin: it registers the approval node on the engine and wires + // the service to the kernel's `i18n` service. + await new ApprovalsServicePlugin().start(ctx); + svc = services.approvals as ApprovalService; + automation.registerFlow(FLOW.name, FLOW as any); + }); + + afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + }); + + it('the request opened with the authored labels, as the engine handed them over', async () => { + const req = await submit(); + expect([req.process_name, req.current_step]).toEqual(['flow:account_approval', 'manager_review']); + expect([req.process_label, req.current_step_label]).toEqual(['Account Approval', 'Manager Review']); + }); + + describe('a zh-CN reader', () => { + it('the record\'s approval panel: the list filtered to the record', async () => { + const req = await submit(); + const rows = await svc.listRequests({ object: OBJECT, recordId: req.record_id }, reader(SUBMITTER, 'zh-CN')); + expect(rows.map((r) => r.id)).toEqual([req.id]); + expect(labelsOf(rows[0])).toEqual(IN_ZH); + }); + + it('the inbox: the list filtered to the approver', async () => { + const req = await submit(); + const rows = await svc.listRequests({ approverId: MANAGER, status: 'pending' }, reader(MANAGER, 'zh-CN')); + const row = rows.find((r) => r.id === req.id); + expect(row, 'the request is in the approver\'s inbox').toBeDefined(); + expect(labelsOf(row)).toEqual(IN_ZH); + }); + + it('a bare `zh` reaches the zh-CN pack', async () => { + const req = await submit(); + expect(labelsOf(await svc.getRequest(req.id, reader(SUBMITTER, 'zh')))).toEqual(IN_ZH); + }); + + it('the get, and its step progress: a step the pack carries in zh-CN, a step it does not as authored', async () => { + const req = await submit(); + const row: any = await svc.getRequest(req.id, reader(MANAGER, 'zh-CN')); + expect(labelsOf(row)).toEqual(IN_ZH); + expect(row.flow_steps).toEqual([ + { id: 'manager_review', label: '经理审核', state: 'current' }, + { id: 'director_review', label: 'Director Review', state: 'upcoming' }, + ]); + }); + + it('a decision\'s echo is served in the decider\'s locale', async () => { + const req = await submit(); + const out = await svc.decide(req.id, { decision: 'approve', actorId: MANAGER }, reader(MANAGER, 'zh-CN')); + expect(out.finalized).toBe(true); + expect(labelsOf(out.request)).toEqual(IN_ZH); + }); + + it('a step the pack does not carry is served as stored, beside the flow label it does', async () => { + const req = await submit(); + await svc.decide(req.id, { decision: 'approve', actorId: MANAGER }, reader(MANAGER)); + const next: any[] = await engine.find('sys_approval_request', { + where: { record_id: req.record_id, status: 'pending' }, context: SYSTEM, + } satisfies EngineQueryOptions); + expect(next.map((r) => r.current_step)).toEqual(['director_review']); + const row = await svc.getRequest(next[0].id, reader(DIRECTOR, 'zh-CN')); + expect(labelsOf(row)).toEqual({ + process_label: '客户审批', + step_label: 'Director Review', + current_step_label: 'Director Review', + object_label: '客户', + }); + }); + }); + + it('a reader in a locale no pack holds is served the stored snapshot', async () => { + const req = await submit(); + expect(labelsOf(await svc.getRequest(req.id, reader(SUBMITTER, 'ja-JP')))).toEqual(AS_STORED); + const rows = await svc.listRequests({ approverId: MANAGER, status: 'pending' }, reader(MANAGER, 'ja-JP')); + expect(labelsOf(rows.find((r) => r.id === req.id))).toEqual(AS_STORED); + }); + + it('an en reader, and a reader with no locale, are served what the door served before', async () => { + const req = await submit(); + for (const locale of ['en', undefined]) { + expect(labelsOf(await svc.getRequest(req.id, reader(SUBMITTER, locale))), String(locale)).toEqual(AS_STORED); + const rows = await svc.listRequests({ object: OBJECT, recordId: req.record_id }, reader(SUBMITTER, locale)); + expect(labelsOf(rows[0]), String(locale)).toEqual(AS_STORED); + } + }); + + it('the stored row keeps the authored text it opened with, after every read', async () => { + const req = await submit(); + await svc.getRequest(req.id, reader(SUBMITTER, 'zh-CN')); + await svc.listRequests({ approverId: MANAGER }, reader(MANAGER, 'zh-CN')); + const row = await stored(req.id); + expect([row.process_label, row.current_step_label]).toEqual(['Account Approval', 'Manager Review']); + }); +}); diff --git a/packages/plugins/plugin-approvals/src/reader-locale-labels.test.ts b/packages/plugins/plugin-approvals/src/reader-locale-labels.test.ts new file mode 100644 index 00000000000..0fbe2efd1af --- /dev/null +++ b/packages/plugins/plugin-approvals/src/reader-locale-labels.test.ts @@ -0,0 +1,181 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24863] The pick `reader-locale-labels.ts` makes, in isolation. + * + * - The keys: each one the reader asks is an address the translation contract + * DECLARES. Every key is turned back into the nested bundle shape a pack + * carries and parsed by `TranslationDataSchema`, so a spelling the `flows` + * face does not declare is red here, not silently a miss at read time. A + * control proves the round trip can fail. + * - The pick: the reader's locale is negotiated against the service's locales + * (`resolveBundleLocale`), a miss (the echoed key, an empty or blank entry) + * or a throwing service picks nothing, and a read with no locale or no + * service picks nothing at all. + * - The overlay: the step is served under two names that move together, and a + * label nothing is picked for keeps the row's own value. + * + * The i18n service is the REAL in-memory implementation (`createMemoryI18n`, + * `@objectstack/core`), loaded the way `AppPlugin` loads a stack's packs. + */ + +import { describe, it, expect } from 'vitest'; +import { createMemoryI18n } from '@objectstack/core'; +import { TranslationDataSchema, objectLabelKey } from '@objectstack/spec/system'; + +import { + approvalStepLabelKey, + flowLabelKey, + flowNameOfProcess, + localizeRequestLabels, + readerLabelsFor, + type ReaderLabelI18n, +} from './reader-locale-labels.js'; + +/** A dot-notation key and its value, as the nested object a language pack carries. */ +function packOf(entries: Record): Record { + const pack: Record = {}; + for (const [key, value] of Object.entries(entries)) { + const parts = key.split('.'); + let at = pack; + for (const part of parts.slice(0, -1)) at = (at[part] ??= {}); + at[parts[parts.length - 1]] = value; + } + return pack; +} + +function i18nWith(packs: Record>) { + const i18n = createMemoryI18n(); + for (const [locale, entries] of Object.entries(packs)) i18n.loadTranslations(locale, packOf(entries)); + return i18n; +} + +const ZH = { + [flowLabelKey('account_approval')]: '客户审批', + [approvalStepLabelKey('account_approval', 'manager_review')]: '经理审核', + [objectLabelKey('account')]: '客户', +}; + +describe('[#24863] every key the reader asks is one the translation contract declares', () => { + it('spells the flow label, the approval step label and the object label', () => { + expect(flowLabelKey('account_approval')).toBe('flows.account_approval.label'); + expect(approvalStepLabelKey('account_approval', 'manager_review')) + .toBe('flows.account_approval.approvals.manager_review.label'); + expect(objectLabelKey('account')).toBe('objects.account.label'); + }); + + it('a pack holding exactly those keys parses, and keeps each value at its address', () => { + const result = TranslationDataSchema.safeParse(packOf(ZH)) as { success: boolean; data?: any; error?: unknown }; + expect(result.success, JSON.stringify(result.error)).toBe(true); + expect(result.data.flows.account_approval.label).toBe('客户审批'); + expect(result.data.flows.account_approval.approvals.manager_review.label).toBe('经理审核'); + expect(result.data.objects.account.label).toBe('客户'); + }); + + it('control: the same round trip refuses a step key the face does not declare', () => { + const result = TranslationDataSchema.safeParse(packOf({ 'flows.account_approval.approvals.manager_review.heading': 'x' })); + expect(result.success).toBe(false); + }); +}); + +describe('[#24863] the flow a process name names', () => { + it('is the name after the `flow:` prefix', () => { + expect(flowNameOfProcess('flow:account_approval')).toBe('account_approval'); + }); + + it('is none for a name without the prefix, or with nothing after it', () => { + expect(flowNameOfProcess('account_approval')).toBeUndefined(); + expect(flowNameOfProcess('flow:')).toBeUndefined(); + expect(flowNameOfProcess(undefined)).toBeUndefined(); + }); +}); + +describe('[#24863] the pick', () => { + it('reads the reader\'s locale, negotiated: a bare `zh` reaches the `zh-CN` pack', () => { + const i18n = i18nWith({ 'zh-CN': ZH }); + for (const locale of ['zh-CN', 'zh']) { + const labels = readerLabelsFor(i18n, locale)!; + expect(labels.flowLabel('account_approval')).toBe('客户审批'); + expect(labels.stepLabel('account_approval', 'manager_review')).toBe('经理审核'); + expect(labels.objectLabel('account')).toBe('客户'); + } + }); + + it('negotiates through `resolveBundleLocale` even for a service that resolves exactly', () => { + // A service that answers only the exact tag it holds, as `FileI18nAdapter` does. + const exact: ReaderLabelI18n = { + t: (key, locale) => (locale === 'zh-CN' && key === flowLabelKey('f') ? '流程' : key), + getLocales: () => ['en', 'zh-CN'], + }; + expect(readerLabelsFor(exact, 'zh')!.flowLabel('f')).toBe('流程'); + }); + + it('picks nothing on a miss: no entry, an empty entry, a blank entry', () => { + const i18n = i18nWith({ 'zh-CN': { [flowLabelKey('empty')]: '', [flowLabelKey('blank')]: ' ' } }); + const labels = readerLabelsFor(i18n, 'zh-CN')!; + expect(labels.flowLabel('account_approval')).toBeUndefined(); + expect(labels.flowLabel('empty')).toBeUndefined(); + expect(labels.flowLabel('blank')).toBeUndefined(); + }); + + it('picks nothing for a locale no pack holds', () => { + expect(readerLabelsFor(i18nWith({ 'zh-CN': ZH }), 'ja-JP')!.flowLabel('account_approval')).toBeUndefined(); + }); + + it('picks nothing from a service that throws, and does not throw itself', () => { + const throwing: ReaderLabelI18n = { + t: () => { throw new Error('i18n exploded'); }, + getLocales: () => { throw new Error('locales exploded'); }, + }; + expect(readerLabelsFor(throwing, 'zh-CN')!.flowLabel('account_approval')).toBeUndefined(); + }); + + it('is no pick at all for a read with no locale, or with no service', () => { + expect(readerLabelsFor(i18nWith({ 'zh-CN': ZH }), undefined)).toBeUndefined(); + expect(readerLabelsFor(i18nWith({ 'zh-CN': ZH }), '')).toBeUndefined(); + expect(readerLabelsFor(undefined, 'zh-CN')).toBeUndefined(); + }); + + it('asks the service once per key per read', () => { + const asked: string[] = []; + const counting: ReaderLabelI18n = { t: (key) => { asked.push(key); return key; } }; + const labels = readerLabelsFor(counting, 'zh-CN')!; + labels.flowLabel('f'); + labels.flowLabel('f'); + expect(asked).toEqual([flowLabelKey('f')]); + }); +}); + +describe('[#24863] the overlay on a served row', () => { + const served = () => ({ + process_name: 'flow:account_approval', + current_step: 'manager_review', + object_name: 'account', + process_label: 'Account Approval', + step_label: 'Manager Review', + current_step_label: 'Manager Review', + object_label: 'Account', + }); + + it('moves the flow label, both names of the step label, and the object label', () => { + const row = served(); + localizeRequestLabels(row, readerLabelsFor(i18nWith({ 'zh-CN': ZH }), 'zh-CN')!); + expect(row).toMatchObject({ + process_label: '客户审批', step_label: '经理审核', current_step_label: '经理审核', object_label: '客户', + }); + }); + + it('keeps each label nothing is picked for', () => { + const row = served(); + localizeRequestLabels(row, readerLabelsFor(i18nWith({ 'zh-CN': { [flowLabelKey('account_approval')]: '客户审批' } }), 'zh-CN')!); + expect(row).toMatchObject({ + process_label: '客户审批', step_label: 'Manager Review', current_step_label: 'Manager Review', object_label: 'Account', + }); + }); + + it('reads no flow key for a process name that names no flow', () => { + const row = { ...served(), process_name: 'account_approval' }; + localizeRequestLabels(row, readerLabelsFor(i18nWith({ 'zh-CN': ZH }), 'zh-CN')!); + expect(row).toMatchObject({ process_label: 'Account Approval', step_label: 'Manager Review', object_label: '客户' }); + }); +}); diff --git a/packages/plugins/plugin-approvals/src/reader-locale-labels.ts b/packages/plugins/plugin-approvals/src/reader-locale-labels.ts new file mode 100644 index 00000000000..eb8f15464eb --- /dev/null +++ b/packages/plugins/plugin-approvals/src/reader-locale-labels.ts @@ -0,0 +1,189 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24863] The approvals door's display labels, the flow's, the step's and the + * subject object's, in the READER's language, picked when the row is read. + * + * ## What was wrong + * + * A request snapshots the flow's and the Approval node's authored labels when + * it opens (`process_label` / `current_step_label`, `openNodeRequest`), and + * `rowFromRequest` served that snapshot to every reader. An app's language pack + * already carries `flows..label` and, since the face gained it, + * `flows..approvals..label`, yet no approval reader consulted + * either, so a zh-CN, ja-JP or es-ES reader saw the authoring language on the + * inbox, the record's approval panel and the request page. The inbox row's + * `object_label` was the object's schema label, untranslated, for the same + * reason. + * + * ## The mechanism, reused + * + * This is how the server already renders a user-read string in the caller's + * language, and ⛔ nothing here is a second way to do it: + * + * - the locale is the door's already-resolved `ExecutionContext.locale` (the + * request's `Accept-Language`, else the `localization` settings); + * - it is negotiated against the locales the `i18n` service holds by + * `resolveBundleLocale` (`@objectstack/spec/system`), the one negotiation + * rule, so a bare `zh` reaches a `zh-CN` pack; + * - the text is `II18nService.t(key, locale)`; a miss is the key echoed back, + * and then the authored text stands. + * + * objectql names a refused write's object this way (`objectDisplayLabel`, + * through `objectLabelKey`), the REST door names a NOT NULL field this way + * (`not-null-refusal-label.ts`), and the automation engine picks a refusal's + * and a screen's template this way (`renderFlowTextSlot`). The negotiation + * glue below is the same few lines each of those consumers carries around the + * one rule; the rule itself is not restated. + * + * ## The keys + * + * - the flow: `flows..label`, the `flows` face's own `label`; + * - the step: `flows..approvals..label`, the face's key for an + * `approval` node's step label (`translation.zod.ts`, `approvals`); + * - the object: `objects..label` (`objectLabelKey`). + * + * `` is the request's `process_name` without its `flow:` prefix, the + * `Flow.name` the engine hands the node (`$flowName`), and `` is its + * `current_step`, the Approval node's `FlowNode.id`. Both are on the stored + * row, so no flow definition is read. `@objectstack/spec/system` exports no + * helper spelling either flow key, so they are spelled here, beside the face's + * own docblock address; `reader-locale-labels.test.ts` holds both spellings to + * `TranslationDataSchema`, so a key the face does not declare cannot be asked. + * + * ## What stays + * + * The stored columns are not touched. `process_label` and `current_step_label` + * stay the authored text as it stood when the request opened: the audit record, + * what the generic data door and the list views serve, and the text this read + * falls back to whenever nothing is picked. Nothing is picked when the read + * carries no locale (a system context, the token-bearing action page), when no + * `i18n` service is registered, when the pack holds no entry for the key, or + * when the service throws. A process name without the `flow:` prefix names no + * flow, so its labels stay as stored. + */ + +import type { II18nService } from '@objectstack/spec/contracts'; +import { objectLabelKey, resolveBundleLocale } from '@objectstack/spec/system'; + +/** The slice of `II18nService` this read asks: `t()`, and `getLocales()` to negotiate when offered. */ +export type ReaderLabelI18n = Pick & Partial>; + +/** The `flows` face's flow label, `flows..label`. */ +export function flowLabelKey(flowName: string): string { + return `flows.${flowName}.label`; +} + +/** The `flows` face's step label of an `approval` node, `flows..approvals..label`. */ +export function approvalStepLabelKey(flowName: string, nodeId: string): string { + return `flows.${flowName}.approvals.${nodeId}.label`; +} + +/** + * The flow a request's `process_name` names: `flow:account_approval` → + * `account_approval`. `undefined` for anything without the prefix, or with + * nothing after it. + */ +export function flowNameOfProcess(processName: unknown): string | undefined { + if (typeof processName !== 'string' || !processName.startsWith('flow:')) return undefined; + const name = processName.slice('flow:'.length).trim(); + return name || undefined; +} + +/** The labels one read picks for its reader; each answers `undefined` when nothing is picked. */ +export interface ReaderLabels { + flowLabel(flowName: string): string | undefined; + stepLabel(flowName: string, nodeId: string): string | undefined; + objectLabel(objectName: string): string | undefined; +} + +/** + * The locale `service` can ANSWER in, for the locale the reader asked for: + * `resolveBundleLocale` over what `getLocales()` reports. The requested tag + * passes through untouched when there is nothing to negotiate against (no + * `getLocales`, a throwing or empty answer, no match). + */ +function negotiatedLocale(service: ReaderLabelI18n, requested: string): string { + if (typeof service.getLocales !== 'function') return requested; + let available: unknown; + try { + available = service.getLocales(); + } catch { + return requested; + } + if (!Array.isArray(available) || available.length === 0) return requested; + const offered: Record = {}; + for (const code of available) { + if (typeof code === 'string' && code.length > 0) offered[code] = true; + } + return resolveBundleLocale(offered, requested) ?? requested; +} + +/** + * The labels for a reader whose `ExecutionContext.locale` is `requested`, or + * `undefined` when this read can pick nothing: no locale, or no service with a + * `t()`. Each answer is memoised for the life of the returned object, which is + * one read. + */ +export function readerLabelsFor( + service: ReaderLabelI18n | undefined, + requested: string | undefined, +): ReaderLabels | undefined { + if (typeof requested !== 'string' || requested.length === 0) return undefined; + if (!service || typeof service.t !== 'function') return undefined; + const locale = negotiatedLocale(service, requested); + const picked = new Map(); + const pick = (key: string): string | undefined => { + if (picked.has(key)) return picked.get(key); + let answer: string | undefined; + try { + const text = service.t(key, locale); + // `II18nService.t` echoes the key back on a miss. + if (typeof text === 'string' && text.trim().length > 0 && text !== key) answer = text; + } catch { + // A misbehaving i18n service must not fail the read: the stored label stands. + answer = undefined; + } + picked.set(key, answer); + return answer; + }; + return { + flowLabel: (flowName) => pick(flowLabelKey(flowName)), + stepLabel: (flowName, nodeId) => pick(approvalStepLabelKey(flowName, nodeId)), + objectLabel: (objectName) => pick(objectLabelKey(objectName)), + }; +} + +/** The members of a served request row this read localizes. */ +export interface LocalizableRequestRow { + process_name?: string; + current_step?: string; + object_name?: string; + process_label?: string; + step_label?: string; + current_step_label?: string; + object_label?: string; +} + +/** + * Overlay `row`'s served display labels with the ones `labels` picks, in + * place. The step's label is served under two names of one value (`step_label` + * and `current_step_label`), and both move together. A label nothing is picked + * for keeps the value the row already carries. + */ +export function localizeRequestLabels(row: LocalizableRequestRow, labels: ReaderLabels): void { + const flow = flowNameOfProcess(row.process_name); + if (flow) { + const flowLabel = labels.flowLabel(flow); + if (flowLabel) row.process_label = flowLabel; + const node = typeof row.current_step === 'string' && row.current_step ? row.current_step : undefined; + const stepLabel = node ? labels.stepLabel(flow, node) : undefined; + if (stepLabel) { + row.step_label = stepLabel; + row.current_step_label = stepLabel; + } + } + const object = typeof row.object_name === 'string' && row.object_name ? row.object_name : undefined; + const objectLabel = object ? labels.objectLabel(object) : undefined; + if (objectLabel) row.object_label = objectLabel; +} From 832d8243acead50d2968fa38bc00592282222d3f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:54:04 +0000 Subject: [PATCH 2/3] chore(changeset): plugin-approvals patch for the reader-locale approval labels Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK --- .changeset/24863-approval-labels-reader-locale.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/24863-approval-labels-reader-locale.md diff --git a/.changeset/24863-approval-labels-reader-locale.md b/.changeset/24863-approval-labels-reader-locale.md new file mode 100644 index 00000000000..5f1e604c205 --- /dev/null +++ b/.changeset/24863-approval-labels-reader-locale.md @@ -0,0 +1,13 @@ +--- +'@objectstack/plugin-approvals': patch +--- + +fix(plugin-approvals): the approvals inbox, the record's approval panel and the request page name the flow, the step and the record's object in the reader's language + +Clause-②: no + +- **What was wrong.** An approval request copies the flow's label and the Approval node's label when it opens (`process_label`, `current_step_label`), and the approvals door served that copy to every reader. A user whose locale is zh-CN, ja-JP or es-ES saw the authoring language, although the app's language pack carried `flows..label` and `flows..approvals..label` for those strings. The inbox row's `object_label` was the object's untranslated schema label. +- **Now.** `GET /api/v1/approvals/requests` (the inbox and the record's approval panel), `GET /api/v1/approvals/requests/:id` and the request a decision answers with serve `process_label`, `step_label` / `current_step_label` and `object_label` in the reader's locale (`Accept-Language`, else the `localization` settings). Each is read from the deployment's `i18n` service: `flows..label`, `flows..approvals..label` and `objects..label`. A bare `zh` reaches a `zh-CN` pack. The request get's step progress (`flow_steps`) names each step the same way. +- **Fallback.** A label the pack does not carry, a locale no pack holds, a read without a locale and a stack without an `i18n` service all get the label the request stored, exactly as before. An `en` reader of an app authored in English sees no change. +- **Unchanged.** The stored `process_label` and `current_step_label` columns keep the authored text they were written with: nothing is migrated or rewritten, and the generic data door, the list views and the one-tap action page still show the stored text. Who may read a request is unchanged. The `approval.requested` notification body is still English. +- **`ApprovalServiceOptions.i18nServiceSource`.** A new optional option: a lazy reader of the `i18n` service. `ApprovalsServicePlugin` wires it from the kernel's `i18n` service. A host that constructs `ApprovalService` by hand and leaves it out keeps the stored labels. From c2242d9973bdd833dd087af1194dc2058f6b6c19 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:57:18 +0000 Subject: [PATCH 3/3] docs(plugin-approvals): the display-label chain answers the authored label; the door overlays the reader's translation Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK --- packages/plugins/plugin-approvals/src/approval-service.ts | 3 +++ packages/plugins/plugin-approvals/src/display-labels.ts | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index 06899aa721b..1cfa9e72bdf 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -3766,6 +3766,9 @@ export class ApprovalService implements IApprovalService { // a reader (`sys-approval-request.object.ts` documents each column). // An empty authored label counts as none: the chain falls through to // the humanized machine name, the label the approvals door served. + // [#24863] The approvals door serves the reader's-locale translation of + // both labels over this snapshot at read time (`reader-locale-labels.ts`); + // the stored text stays the authored one. process_label: labelOrMachineName(input.flowLabel, processName) ?? null, current_step: input.nodeId, current_step_label: labelOrMachineName(input.nodeLabel, input.nodeId) ?? null, diff --git a/packages/plugins/plugin-approvals/src/display-labels.ts b/packages/plugins/plugin-approvals/src/display-labels.ts index 02bfd357e4a..f161f5c2286 100644 --- a/packages/plugins/plugin-approvals/src/display-labels.ts +++ b/packages/plugins/plugin-approvals/src/display-labels.ts @@ -23,6 +23,14 @@ * string counts as no label — the spec allows one (`FlowSchema.label` and * `FlowNodeSchema.label` are `z.string()` with no minimum) — so the chain falls * through to the machine name instead of showing a blank cell. + * + * [#24863] The chain answers the AUTHORED label, in the language the flow was + * written in. The approvals door then serves the reader's-locale translation + * over it where the reader's language pack carries one + * (`reader-locale-labels.ts`), a read-time pick that never writes the columns. + * So the lists and the door show the same label to a reader with no + * translation, and a reader whose pack translates the flow or the step sees + * the translation on the door and the stored text on the lists. */ /**