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. diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index b3abe2b75a6..1cfa9e72bdf 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()) @@ -3746,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, @@ -8164,10 +8187,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 +8329,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 +8440,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 +8458,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/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. */ /** 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; +}