Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/24863-approval-labels-reader-locale.md
Original file line number Diff line number Diff line change
@@ -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.<flow>.label` and `flows.<flow>.approvals.<node_id>.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.<flow>.label`, `flows.<flow>.approvals.<node_id>.label` and `objects.<object>.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.
66 changes: 63 additions & 3 deletions packages/plugins/plugin-approvals/src/approval-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.<flow>.label`, `flows.<flow>.approvals.<node_id>.label`
* and `objects.<object>.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<IApprovalService['listRequests']>[0],
context: ExecutionContext,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.<flow>.approvals.<node_id>.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<void> {
private async attachFlowSteps(row: ApprovalRequestRow, labels: ReaderLabels | undefined): Promise<void> {
try {
const flowName = row.process_name?.startsWith('flow:') ? row.process_name.slice(5) : undefined;
if (!flowName || typeof this.automation?.getFlow !== 'function') return;
Expand All @@ -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')
Expand Down
31 changes: 22 additions & 9 deletions packages/plugins/plugin-approvals/src/approvals-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<II18nService>('i18n');
return typeof i18n?.t === 'function' ? i18n : undefined;
} catch {
return undefined;
}
};

this.service = new ApprovalService({
engine: engine as ApprovalEngine,
logger: ctx.logger,
Expand Down Expand Up @@ -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<II18nService>('i18n');
const t = i18n?.t;
if (typeof t !== 'function') return undefined;
return (key: string, locale: string, params?: Record<string, unknown>) =>
t.call(i18n, key, locale, params);
} catch {
return undefined;
}
const i18n = i18nService();
if (!i18n) return undefined;
return (key: string, locale: string, params?: Record<string, unknown>) =>
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.
Expand Down
8 changes: 8 additions & 0 deletions packages/plugins/plugin-approvals/src/display-labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/

/**
Expand Down
Loading
Loading