Skip to content

Commit ca80f05

Browse files
fix(plugin-approvals): store the admin_rescue timeline note as a code and render it in the reader's language (#25627)
Fixes #25576 Clause-②: no Metadata-protocol: none ## What changed The system note an `admin_rescue` request writes on its own timeline ("Routed to this organization's administrators because the approver slate named no one: position 'X' has no holder.") was composed server-side as one English sentence and stored as the `comment` of a `sys_approval_action` row, and `listActions` served it as stored, so a zh-CN reader read English. Now the note is stored as a code plus its parameters, and the approvals door renders it in the reader's language: - **Stored shape.** `openNodeRequest` records the note on the request's node-config snapshot as `__adminRescueNote: { action_id, abandoned, fallback? }`: the id of the timeline row it explains, the abandoned approver slots (`type:value`), and, when a declared `onEmptyApprovers: 'fallback'` did not help, why (`not_declared` / `resolved_to_no_one`). The snapshot is where a request already keeps what it resolved at open time (`__flowLabel`, `__round`, `__resolvedFrom`; the open path's own comment: "Stamped on the snapshot (precedent: __flowLabel), so no schema migration"), so no column, object schema or spec contract moves. The row's `comment` is still the English sentence, byte-identical to what it stored before. - **Catalog.** The sentence and its clauses are new entries in the plugin's existing `messages` face (`translations/messages.ts`, `approvalAdminRescue*`), in `en`, `zh-CN`, `ja-JP` and `es-ES`. The stored English is those `en` entries interpolated, never a second spelling (`admin-rescue-note.ts`). The `admin_rescue:reason` audit string and the log lines are built from the same clauses (the module-private `emptySlateReason` is replaced by `adminRescueReason`). - **Read path.** `listActions` (`GET /api/v1/approvals/requests/:id/actions`) finds the row the snapshot names and overlays its `comment`, in place, with the note rendered at the caller's `ExecutionContext.locale`. A read, never a write. The served `ApprovalActionRow` shape is unchanged. ## Precedent reused (no second mechanism) - **Reader-language render:** `reader-locale-labels.ts` (#24863) is how the approvals door already serves a request's flow, step and object labels in the reader's language: `ExecutionContext.locale`, negotiated by `resolveBundleLocale`, rendered by `II18nService.t()`, with a miss leaving the stored text standing. This PR adds one member, `ReaderLabels.sentence(key, params)`, on the same `t()` and the same negotiated locale. - **Catalog of keyed sentences with the English literal beside the keys:** `approval-notification-text.ts` + `translations/messages.ts` (#24978). Its `{{hole}}` interpolation is now exported and used for the stored English. ## Who renders it, and the objectui half The server half is complete on the approvals door: any reader of `GET …/approvals/requests/:id/actions` with a locale gets the localized note. Not verified from this session: which door the console's record Approvals tab reads its timeline through (objectui is not attached here). If `RecordApprovalsPanel` reads `sys_approval_action` through the generic data door rather than the approvals actions route, it is served the stored English, and the objectui half is to read the timeline through the approvals door. ## Rows already stored A request opened before this change has no `__adminRescueNote` on its snapshot, so its note is served as stored, in English. No migration: the stored text stays correct English, it is display-only audit text, and its clauses survive only inside that prose. Parsing prose back into a code is the defect this replaces, so no backfill reconstructs them. Pinned by the `CONTROL` case below. ## Serial constraint PR #25600 (SLA reassign) landed on `main` while this was in flight, so this branch merged `origin/main` (`b9c264fd8`, clean, no conflict). The diff stays out of the SLA-reassign bodies. ## Verification (head `b9c264fd8`) - `pnpm --filter @objectstack/plugin-approvals typecheck`: exit 0. `pnpm --filter @objectstack/plugin-approvals test`: Test Files 96 passed (96), Tests 1259 passed (1259). - New pins: `admin-rescue-note.test.ts` (the English is the sentence the row always stored; zh-CN / ja-JP render every clause and the separator; a clause the pack lacks falls back to English; a missing note sentence renders nothing; the snapshot reader accepts only the shape it writes). `admin-rescue-named-slate.integration.test.ts` adds five cases on a real `ObjectQL` + `SqlDriver` with a real in-memory `i18n` service loaded with the plugin's own `ApprovalsTranslations`. They pin: the snapshot stores the code and names the row; a zh-CN reader of `listActions` reads `审批人名单未指向任何人,因此已转交本组织管理员处理:岗位「sales_manager」无人任职。`; the fallback case renders both clauses joined the zh way; en and no-locale readers get the stored English; and a CONTROL where a pre-change request and a neighbouring system comment stay as stored. - Ablation (deleting the `localizeAdminRescueNote` call in `listActions`, through `scripts/ablation-replace.mjs`): 3 failed / 13 passed in the integration file, with the zh-CN, fallback and CONTROL cases red. Restore verified byte-identical: blob after restore `3527886551f8` = HEAD, `git diff HEAD` empty. - `approver-address-readers.test.ts`: the new `actor_id` presence read is classified in `PERSON_SITES` (role `display`, count 2), as that pin requires of every read of the column. - Derived gates (`node scripts/pm/dispatch-gates.mjs --commands`): all 72 run on `b9c264fd8`, every one exit 0. `--ran` reconciliation: "72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN". `check:i18n`: "all bundles in sync, no undeclared authoring keys". eslint `--no-inline-config` on the 8 touched `.ts` files: exit 0. ## Acceptance notes - The `step_name` / `step_label` half of the original card is not in this PR: triage dropped it as objectui#13248 (closed as completed). --- _Generated by [Claude Code](https://claude.ai/code/session_01QeMwrPsf2zjo2pQLBzdWFV)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QeMwrPsf2zjo2pQLBzdWFV --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent abdc7f2 commit ca80f05

9 files changed

Lines changed: 426 additions & 45 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@objectstack/plugin-approvals": patch
3+
---
4+
5+
Clause-②: no
6+
Metadata-protocol: none
7+
8+
The note an `admin_rescue` approval request writes on its own timeline ("Routed to this organization's administrators because the approver slate named no one: …") is now read in the reader's language. The request's node-config snapshot stores the note as a code and its parameters (`__adminRescueNote`: the timeline row it explains, the abandoned approver slots, and why a declared `fallback` did not help), and `listActions` (`GET /api/v1/approvals/requests/:id/actions`) renders that row's `comment` through the plugin's `messages` catalog at the caller's locale, the way the approvals door already localizes a request's flow, step and object labels. The sentence and its clauses ship in `en`, `zh-CN`, `ja-JP` and `es-ES`.
9+
10+
The stored `comment` is unchanged: it is still the English sentence, which a reader with no locale, an `en` reader, the generic data door and the action-history list views are served as before. Requests opened before this release carry no snapshot entry, so their note keeps showing its stored English; no migration is needed.

‎packages/plugins/plugin-approvals/src/admin-rescue-named-slate.integration.test.ts‎

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,12 @@
4242
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
4343
import { ObjectQL } from '@objectstack/objectql';
4444
import { SqlDriver } from '@objectstack/driver-sql';
45-
import { bindSecurityCatalogReader, type SecurityCatalogEntry, type SecurityCatalogReader } from '@objectstack/core';
45+
import {
46+
bindSecurityCatalogReader, createMemoryI18n, type SecurityCatalogEntry, type SecurityCatalogReader,
47+
} from '@objectstack/core';
4648
import { ApprovalService } from './approval-service.js';
4749
import { ApprovalsServicePlugin } from './approvals-plugin.js';
50+
import { ApprovalsTranslations } from './translations/index.js';
4851
import { relaxTenantColumns } from './pre-18-tenant-column.testkit.js';
4952

5053
const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any;
@@ -341,4 +344,84 @@ describe('an empty admin_rescue slate opens on the organization\'s administrator
341344
expect(Boolean(decision?.via_override)).toBe(true);
342345
});
343346
});
347+
348+
// [#25576] The note is stored as a code and its parameters, and the
349+
// approvals door renders it in the reader's language through the plugin's
350+
// own `messages` catalog, loaded into a real in-memory `i18n` service the
351+
// way the plugin loads it at `kernel:ready`.
352+
describe('[#25576] the note is stored as data and read in the reader\'s language', () => {
353+
let reader: ApprovalService;
354+
const sub = (locale?: string) => ({ ...as('u_sub', ORG), ...(locale ? { locale } : {}) });
355+
const noteOf = async (requestId: string, locale?: string) => {
356+
const served = await reader.listActions(requestId, sub(locale));
357+
return served.filter((a) => a.action === 'comment' && !a.actor_id);
358+
};
359+
const ENGLISH = "Routed to this organization's administrators because the approver slate named no one: "
360+
+ "position 'sales_manager' has no holder.";
361+
362+
beforeAll(() => {
363+
const i18n = createMemoryI18n();
364+
for (const [locale, data] of Object.entries(ApprovalsTranslations)) {
365+
i18n.loadTranslations(locale, data as Record<string, unknown>);
366+
}
367+
reader = new ApprovalService({
368+
engine: engine as any,
369+
clock: { now: () => new Date(now += 1000) },
370+
logger: { warn: () => {}, info: () => {}, error: () => {}, debug: () => {} } as any,
371+
i18nServiceSource: () => i18n,
372+
});
373+
});
374+
375+
it('the snapshot stores the note\'s code and parameters, naming the row; the row stores its English', async () => {
376+
const row = await open('note_data', UNSTAFFED);
377+
const [note] = (await actionsOf(row.id)).filter((a) => a.action === 'comment');
378+
expect(note.comment).toBe(ENGLISH);
379+
expect((await snapshotOf(row.id)).__adminRescueNote).toEqual({
380+
action_id: note.id, abandoned: ['position:sales_manager'],
381+
});
382+
});
383+
384+
it('a zh-CN reader of the timeline reads the note in Chinese', async () => {
385+
const row = await open('note_zh', UNSTAFFED);
386+
const served = await noteOf(row.id, 'zh-CN');
387+
expect(served.map((a) => a.comment)).toEqual(['审批人名单未指向任何人,因此已转交本组织管理员处理:岗位「sales_manager」无人任职。']);
388+
// A read, never a write: the stored row still carries the English.
389+
expect((await actionsOf(row.id)).find((a) => a.id === served[0].id)?.comment).toBe(ENGLISH);
390+
});
391+
392+
it('a fallback that found no one renders both clauses, joined the reader\'s way', async () => {
393+
const row = await open('note_zh_fallback', UNSTAFFED, {
394+
onEmptyApprovers: 'fallback', fallbackApprovers: [{ type: 'position', value: 'treasury' }],
395+
});
396+
expect((await snapshotOf(row.id)).__adminRescueNote).toMatchObject({ fallback: 'resolved_to_no_one' });
397+
expect((await noteOf(row.id, 'zh-CN')).map((a) => a.comment)).toEqual([
398+
'审批人名单未指向任何人,因此已转交本组织管理员处理:岗位「sales_manager」无人任职;'
399+
+ '声明的 fallbackApprovers 也未解析到任何人。',
400+
]);
401+
});
402+
403+
it('an en reader, and a reader with no locale, are served the stored English', async () => {
404+
const row = await open('note_en', UNSTAFFED);
405+
for (const locale of ['en', undefined]) {
406+
expect((await noteOf(row.id, locale)).map((a) => a.comment), String(locale)).toEqual([ENGLISH]);
407+
}
408+
});
409+
410+
it('CONTROL: a request opened before the note was data, and another system comment, stay as stored', async () => {
411+
const row = await open('note_legacy', UNSTAFFED);
412+
// The stored shape of a request opened before #25576: no snapshot entry.
413+
const snapshot = await snapshotOf(row.id);
414+
delete snapshot.__adminRescueNote;
415+
await engine.update('sys_approval_request', { id: row.id, node_config_json: JSON.stringify(snapshot) },
416+
{ context: SYSTEM } as any);
417+
expect((await noteOf(row.id, 'zh-CN')).map((a) => a.comment)).toEqual([ENGLISH]);
418+
419+
const noted = await open('note_neighbour', UNSTAFFED);
420+
await reader.comment(noted.id, { actorId: 'system', comment: 'Checked by the nightly audit.' }, SYSTEM);
421+
expect((await noteOf(noted.id, 'zh-CN')).map((a) => a.comment)).toEqual([
422+
'审批人名单未指向任何人,因此已转交本组织管理员处理:岗位「sales_manager」无人任职。',
423+
'Checked by the nightly audit.',
424+
]);
425+
});
426+
});
344427
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25576] The administrators' routing note: one English source, the reader's
5+
* language from the same catalog, and a strict read of the stored shape.
6+
* The end-to-end half (the snapshot an opened request stores, and the door a
7+
* zh-CN reader reads it through) is `admin-rescue-named-slate.integration.test.ts`.
8+
*/
9+
10+
import { describe, it, expect } from 'vitest';
11+
import { adminRescueNoteOf, adminRescueNoteText, adminRescueReason, type ApprovalSentenceRender } from './admin-rescue-note.js';
12+
import { approvalMessages } from './translations/messages.js';
13+
14+
/** A render over one shipped locale of the catalog, `{{hole}}` filled the `t()` way. */
15+
const renderIn = (locale: keyof typeof approvalMessages, drop: string[] = []): ApprovalSentenceRender =>
16+
(key, params) => {
17+
const id = key.replace(/^messages\./, '');
18+
if (drop.includes(id)) return undefined;
19+
const template = (approvalMessages[locale] as Record<string, string>)[id];
20+
return template?.replace(/\{\{(\w+)\}\}/g, (hole, name: string) => params[name] ?? hole);
21+
};
22+
23+
describe('[#25576] the admin_rescue note', () => {
24+
it('the English is the sentence the row has always stored', () => {
25+
expect(adminRescueNoteText({ abandoned: ['position:sales_manager', 'team:ops'] })).toBe(
26+
"Routed to this organization's administrators because the approver slate named no one: "
27+
+ "position 'sales_manager' has no holder; team 'ops' resolved to no one.",
28+
);
29+
expect(adminRescueReason([], 'not_declared')).toBe(
30+
"no approver on the node resolved to a person; onEmptyApprovers is 'fallback' but no fallbackApprovers are declared",
31+
);
32+
});
33+
34+
it('a reader\'s language renders every clause and the separator from its own pack', () => {
35+
const note = { abandoned: ['position:sales_manager', 'team:ops'], fallback: 'resolved_to_no_one' as const };
36+
expect(adminRescueNoteText(note, renderIn('zh-CN'))).toBe(
37+
'审批人名单未指向任何人,因此已转交本组织管理员处理:岗位「sales_manager」无人任职;'
38+
+ 'team「ops」未解析到任何人;声明的 fallbackApprovers 也未解析到任何人。',
39+
);
40+
expect(adminRescueNoteText(note, renderIn('ja-JP'))).toContain('役職「sales_manager」に就いている人がいません');
41+
});
42+
43+
it('a clause the pack lacks falls back to its English; a missing note sentence renders nothing', () => {
44+
const note = { abandoned: ['position:sales_manager'] };
45+
expect(adminRescueNoteText(note, renderIn('zh-CN', ['approvalAdminRescuePositionUnheld']))).toBe(
46+
"审批人名单未指向任何人,因此已转交本组织管理员处理:position 'sales_manager' has no holder。",
47+
);
48+
expect(adminRescueNoteText(note, renderIn('zh-CN', ['approvalAdminRescueNote']))).toBeUndefined();
49+
});
50+
51+
it('reads only the shape it writes', () => {
52+
const good = { action_id: 'aact_1', abandoned: ['position:p'], fallback: 'not_declared' };
53+
expect(adminRescueNoteOf({ __adminRescueNote: good })).toEqual(good);
54+
expect(adminRescueNoteOf({ __adminRescueNote: { action_id: 'aact_1', abandoned: [] } }))
55+
.toEqual({ action_id: 'aact_1', abandoned: [] });
56+
for (const bad of [
57+
undefined,
58+
{},
59+
{ __adminRescueNote: 'Routed to …' },
60+
{ __adminRescueNote: { abandoned: [] } },
61+
{ __adminRescueNote: { action_id: 'aact_1', abandoned: [1] } },
62+
{ __adminRescueNote: { action_id: 'aact_1', abandoned: [], fallback: 'nope' } },
63+
]) {
64+
expect(adminRescueNoteOf(bad), JSON.stringify(bad)).toBeUndefined();
65+
}
66+
});
67+
});
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25576] The note an `admin_rescue` request writes on its own timeline, as a
5+
* stored code and its parameters, rendered in the READER's language.
6+
*
7+
* ## What was wrong
8+
*
9+
* When an approver slate named no one and the request opened on the
10+
* organization's administrators (#22824), `openNodeRequest` composed one
11+
* English sentence ("Routed to this organization's administrators because the
12+
* approver slate named no one: position 'X' has no holder.") and stored it as
13+
* the `comment` of a system `sys_approval_action` row. The approvals door
14+
* served that comment as stored, so a zh-CN, ja-JP or es-ES reader of the
15+
* timeline read English.
16+
*
17+
* ## The stored shape
18+
*
19+
* The request's node-config snapshot carries the note as data, under
20+
* {@link ADMIN_RESCUE_NOTE_SNAPSHOT_KEY}: the id of the timeline row it
21+
* explains, the abandoned slate and why a declared `fallback` did not help.
22+
* The snapshot, not a new column, is where a request already keeps what it
23+
* resolved at open time (`__flowLabel`, `__round`, `__resolvedFrom`), so no
24+
* schema moves. The row's `comment` stays the English rendering: the audit
25+
* text the generic data door, the action-history list views and every reader
26+
* without a locale are served, exactly as before.
27+
*
28+
* ## The render, reused
29+
*
30+
* The sentence and its clauses are catalog entries in this plugin's `messages`
31+
* face (`./translations/messages.ts`, `approvalAdminRescue*`). The English is
32+
* those `en` entries interpolated here, ⛔ never a second spelling; the
33+
* reader's language is the same entries through `II18nService.t()` at the
34+
* reader's negotiated locale (`ReaderLabels.sentence`, `reader-locale-labels.ts`
35+
* — the one way the approvals door picks a reader's language). A clause the
36+
* pack does not hold falls back to its English; a note sentence the pack does
37+
* not hold leaves the stored comment as it is.
38+
*
39+
* A row written before this note existed has no snapshot entry, so it is served
40+
* as stored: the English it always carried. Its clauses survive only inside that
41+
* prose, and parsing prose back into a code is the defect this module replaces,
42+
* so no backfill recovers them.
43+
*/
44+
45+
import { approvalMessageKey, interpolate } from './approval-notification-text.js';
46+
import { enMessages, type ApprovalMessageId } from './translations/messages.js';
47+
48+
/** Why a declared `onEmptyApprovers: 'fallback'` did not name anyone. */
49+
export type AdminRescueFallback = 'not_declared' | 'resolved_to_no_one';
50+
51+
/** The node-config snapshot key the note rides under. */
52+
export const ADMIN_RESCUE_NOTE_SNAPSHOT_KEY = '__adminRescueNote';
53+
54+
/** The note as the request's snapshot stores it. */
55+
export interface AdminRescueNote {
56+
/** The `sys_approval_action` row (an `action: 'comment'` with no actor) this note is. */
57+
readonly action_id: string;
58+
/** The slate that named no one, as resolved: `type:value` slots and anything else it held. */
59+
readonly abandoned: readonly string[];
60+
/** Set when the node declared `onEmptyApprovers: 'fallback'` and it did not help. */
61+
readonly fallback?: AdminRescueFallback;
62+
}
63+
64+
/** One catalog sentence and its params. */
65+
interface Clause {
66+
readonly id: ApprovalMessageId;
67+
readonly params: Readonly<Record<string, string>>;
68+
}
69+
70+
/** Renders a catalog sentence in some language, or answers `undefined` when it cannot. */
71+
export type ApprovalSentenceRender = (
72+
key: string,
73+
params: Readonly<Record<string, string>>,
74+
) => string | undefined;
75+
76+
/** The reason's clauses: one per abandoned `type:value` slot, then the fallback's. */
77+
function reasonClauses(abandoned: readonly string[], fallback: AdminRescueFallback | undefined): Clause[] {
78+
const clauses: Clause[] = abandoned
79+
.filter((slot) => !!slot && slot.includes(':'))
80+
.map((slot): Clause => {
81+
const at = slot.indexOf(':');
82+
const type = slot.slice(0, at);
83+
const value = slot.slice(at + 1);
84+
return type === 'position'
85+
? { id: 'approvalAdminRescuePositionUnheld', params: { value } }
86+
: { id: 'approvalAdminRescueSlotUnresolved', params: { type, value } };
87+
});
88+
if (!clauses.length) clauses.push({ id: 'approvalAdminRescueNoApprover', params: {} });
89+
if (fallback === 'resolved_to_no_one') clauses.push({ id: 'approvalAdminRescueFallbackUnresolved', params: {} });
90+
if (fallback === 'not_declared') clauses.push({ id: 'approvalAdminRescueFallbackUndeclared', params: {} });
91+
return clauses;
92+
}
93+
94+
const english = (clause: Clause): string => interpolate(enMessages[clause.id], clause.params);
95+
96+
/**
97+
* Why the slate named no one, in English: the audit snapshot's
98+
* `admin_rescue:reason` and the log lines.
99+
*/
100+
export function adminRescueReason(abandoned: readonly string[], fallback: AdminRescueFallback | undefined): string {
101+
return reasonClauses(abandoned, fallback).map(english).join(enMessages.approvalAdminRescueReasonSeparator);
102+
}
103+
104+
/**
105+
* The note's text. With no `render`, the English the row stores; with one,
106+
* the reader's language, or `undefined` when the note sentence itself is not
107+
* rendered (the stored comment then stands).
108+
*/
109+
export function adminRescueNoteText(note: Omit<AdminRescueNote, 'action_id'>): string;
110+
export function adminRescueNoteText(
111+
note: Omit<AdminRescueNote, 'action_id'>,
112+
render: ApprovalSentenceRender,
113+
): string | undefined;
114+
export function adminRescueNoteText(
115+
note: Omit<AdminRescueNote, 'action_id'>,
116+
render?: ApprovalSentenceRender,
117+
): string | undefined {
118+
const clauses = reasonClauses(note.abandoned, note.fallback);
119+
if (!render) {
120+
const reason = clauses.map(english).join(enMessages.approvalAdminRescueReasonSeparator);
121+
return interpolate(enMessages.approvalAdminRescueNote, { reason });
122+
}
123+
const separator = render(approvalMessageKey('approvalAdminRescueReasonSeparator'), {})
124+
?? enMessages.approvalAdminRescueReasonSeparator;
125+
const reason = clauses
126+
.map((clause) => render(approvalMessageKey(clause.id), clause.params) ?? english(clause))
127+
.join(separator);
128+
return render(approvalMessageKey('approvalAdminRescueNote'), { reason });
129+
}
130+
131+
/**
132+
* The note a parsed node-config snapshot carries, or `undefined` when it
133+
* carries none or one this module did not write (the stored comment stands).
134+
*/
135+
export function adminRescueNoteOf(snapshot: unknown): AdminRescueNote | undefined {
136+
if (!snapshot || typeof snapshot !== 'object') return undefined;
137+
const note = (snapshot as Record<string, unknown>)[ADMIN_RESCUE_NOTE_SNAPSHOT_KEY];
138+
if (!note || typeof note !== 'object') return undefined;
139+
const { action_id, abandoned, fallback } = note as Record<string, unknown>;
140+
if (typeof action_id !== 'string' || !action_id) return undefined;
141+
if (!Array.isArray(abandoned) || !abandoned.every((slot) => typeof slot === 'string')) return undefined;
142+
if (fallback !== undefined && fallback !== 'not_declared' && fallback !== 'resolved_to_no_one') return undefined;
143+
return { action_id, abandoned, ...(fallback ? { fallback } : {}) };
144+
}

‎packages/plugins/plugin-approvals/src/approval-notification-text.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,10 @@ const HOLE = /\{\{(\w+)\}\}/g;
9595
/**
9696
* `{{name}}` interpolation with the convention both `II18nService.t()`
9797
* implementations use — one pass, a value is never re-scanned, a hole with no
98-
* value stays as written — over each param's literal text.
98+
* value stays as written — over each param's literal text. Also the `en`
99+
* rendering of the administrators' routing note (`admin-rescue-note.ts`).
99100
*/
100-
function interpolate(template: string, params: Readonly<Record<string, ApprovalTextParam>>): string {
101+
export function interpolate(template: string, params: Readonly<Record<string, ApprovalTextParam>>): string {
101102
return template.replace(HOLE, (hole, name: string) => {
102103
const param = params[name];
103104
if (param === undefined) return hole;

0 commit comments

Comments
 (0)