Skip to content

Commit 5d12675

Browse files
os-zhuangclaude
andauthored
fix(plugin-approvals): unify the approval-status vocabulary across the i18n bundles (#7232) (#7271)
zh-CN said 待处理 for the status and 我的待办 for the my_pending view, while the Approvals Inbox and the account-app nav said 待审批 / 待我审批 for the same things, and the en bundle shipped the raw enum values as labels. Align the bundles to the Inbox wording and humanize en. The bundles are the source of truth for the leaf strings despite the `.generated.ts` name: the extractor generates the STRUCTURE and `--merge` preserves every hand-translated value. Measured both ways -- a hand-edited leaf value keeps `check:i18n` green, a dropped option key turns it red. Adds a pin that resolves every assertion through the real i18n resolver against the real object, including a cross-package parity case tying the my_pending view label to the account-app nav label -- the assertion that goes red if either layer is reworded alone. Status values are untouched; this is display wording only. Claude-Session: https://claude.ai/code/session_01L9U1G2piXmYrhYQX96XUyv Co-authored-by: Claude <noreply@anthropic.com>
1 parent ea8e849 commit 5d12675

6 files changed

Lines changed: 161 additions & 10 deletions

File tree

.changeset/hip-planes-shake.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
---
4+
5+
Unify the approval-status vocabulary across the `sys_approval_request` i18n bundles (#7232).
6+
7+
A request rendered through the generic object surfaces used a different word than the same
8+
request in the Approvals Inbox and in the account-app navigation. The bundles now say what
9+
those surfaces already say:
10+
11+
- **zh-CN**: the `status` option `pending` reads 待审批 (was 待处理), and the `my_pending`
12+
view reads 待我审批 (was 我的待办), matching the account-app nav entry; the view's
13+
empty-state title was aligned to the same wording.
14+
- **en**: the `status` options are humanized — `Pending` / `Approved` / `Rejected` /
15+
`Recalled` / `Returned` — instead of shipping the raw enum values as labels.
16+
- **ja-JP / es-ES**: the `my_pending` view label now matches the nav wording (承認待ち /
17+
Aprobaciones pendientes).
18+
19+
Status **values** are unchanged — this is display wording only, so no stored data, filter,
20+
or API payload is affected.
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Approval-status vocabulary pin (#7232 — problem 4 of #7213: "three
4+
// vocabularies for one request").
5+
//
6+
// `sys_approval_request` is rendered by three unrelated surfaces — the generic
7+
// object views driven by this plugin's bundles, the Approvals Inbox (objectui
8+
// `approvalsInbox.*`), and the account-app navigation owned by
9+
// `@objectstack/platform-objects`. They share no keys, so nothing made them
10+
// agree, and they did not: zh said 待处理 for the status and 我的待办 for the
11+
// `my_pending` view while the other two said 待审批 / 待我审批, and the en bundle
12+
// shipped the raw enum values (`pending`, `approved`, …) as labels.
13+
//
14+
// What this file pins is the AGREEMENT, not the file contents. Two properties
15+
// it would be easy to assert and worthless to:
16+
//
17+
// • Re-reading `zhCNObjects.…options.pending` and expecting 待审批 restates
18+
// the line it is guarding. Every assertion here goes through the real
19+
// resolver (`translateObject` / `resolveViewLabel`) against the real
20+
// `SysApprovalRequest`, so it also proves the bundle is REACHED — the
21+
// declared option label is the bare enum value (see the guard-the-guard
22+
// case), so a bundle that stopped being consulted would surface here as
23+
// raw values rather than as a silent pass.
24+
//
25+
// • Pinning each locale in isolation lets the layers drift apart again, which
26+
// is the whole defect. The parity case compares this plugin's `my_pending`
27+
// view label against the account-app nav label in platform-objects, so
28+
// moving either side alone goes red.
29+
//
30+
// en is deliberately NOT in the parity set: its nav entry reads "Approvals"
31+
// (the destination) while the view reads "My Pending" (the filter), and #7232's
32+
// glossary keeps that split. Only the locales whose two layers say the same
33+
// thing are pinned to keep saying it.
34+
35+
import { describe, it, expect } from 'vitest';
36+
import { APPROVAL_STATUSES } from '@objectstack/spec/contracts';
37+
import { translateObject, resolveViewLabel } from '@objectstack/spec/system';
38+
import type { TranslationData } from '@objectstack/spec/system';
39+
import { zhCN, jaJP, esES } from '@objectstack/platform-objects/apps';
40+
41+
import { ApprovalsTranslations } from './index.js';
42+
import { SysApprovalRequest } from '../sys-approval-request.object.js';
43+
44+
/** Status option labels as a consumer sees them after i18n resolution. */
45+
const resolvedStatusLabels = (locale: string): Record<string, string> => {
46+
const doc = translateObject(SysApprovalRequest as any, ApprovalsTranslations, { locale });
47+
const options = (doc as any)?.fields?.status?.options as
48+
| Array<{ value: string; label?: string }>
49+
| undefined;
50+
return Object.fromEntries((options ?? []).map((o) => [o.value, o.label ?? '']));
51+
};
52+
53+
const resolvedMyPendingLabel = (locale: string): string =>
54+
resolveViewLabel(ApprovalsTranslations, (SysApprovalRequest as any).listViews.my_pending, {
55+
locale,
56+
});
57+
58+
const navApprovalsLabel = (data: TranslationData): string | undefined =>
59+
(data as any)?.apps?.account?.navigation?.nav_account_approvals?.label;
60+
61+
describe('approval status vocabulary (#7232)', () => {
62+
it('guard the guard: the DECLARED option label is the bare enum value', () => {
63+
// `Field.select([...APPROVAL_STATUSES])` normalizes each bare string to
64+
// `{ label: 'pending', value: 'pending' }` — the label IS the value. Every
65+
// humanized label below therefore comes from the bundle and nowhere else;
66+
// without this case a resolver that silently stopped consulting the bundle
67+
// could still satisfy an en expectation of "pending".
68+
const declared = (SysApprovalRequest as any).fields.status.options as Array<{
69+
value: string;
70+
label?: string;
71+
}>;
72+
expect(declared.length).toBe(APPROVAL_STATUSES.length);
73+
expect(declared.map((o) => o.label)).toEqual(declared.map((o) => o.value));
74+
});
75+
76+
it('en humanizes every status instead of shipping the raw enum value', () => {
77+
expect(resolvedStatusLabels('en')).toEqual({
78+
pending: 'Pending',
79+
approved: 'Approved',
80+
rejected: 'Rejected',
81+
recalled: 'Recalled',
82+
returned: 'Returned',
83+
});
84+
});
85+
86+
it('zh-CN says 待审批 for pending — the Approvals Inbox wording', () => {
87+
expect(resolvedStatusLabels('zh-CN')).toEqual({
88+
pending: '待审批',
89+
approved: '已批准',
90+
rejected: '已拒绝',
91+
recalled: '已撤回',
92+
returned: '已退回修改',
93+
});
94+
});
95+
96+
it('no locale leaks a raw enum value as a status label', () => {
97+
// Ratchet for statuses and locales added later: a new entry that reaches a
98+
// bundle un-translated is seeded from the source text, which is the enum
99+
// value itself, so this case catches it without naming it.
100+
for (const locale of ['en', 'zh-CN', 'ja-JP', 'es-ES']) {
101+
const labels = resolvedStatusLabels(locale);
102+
expect(Object.keys(labels).sort()).toEqual([...APPROVAL_STATUSES].sort());
103+
const raw = Object.entries(labels)
104+
.filter(([value, label]) => value === label)
105+
.map(([value]) => value);
106+
expect(raw, `${locale} renders these statuses as their raw enum value`).toEqual([]);
107+
}
108+
});
109+
110+
it('the my_pending view carries the per-locale glossary wording', () => {
111+
expect(resolvedMyPendingLabel('en')).toBe('My Pending');
112+
expect(resolvedMyPendingLabel('zh-CN')).toBe('待我审批');
113+
expect(resolvedMyPendingLabel('ja-JP')).toBe('承認待ち');
114+
expect(resolvedMyPendingLabel('es-ES')).toBe('Aprobaciones pendientes');
115+
});
116+
117+
it('the my_pending view label matches the account-app nav label (problem 4)', () => {
118+
// The cross-layer half: the object view and the navigation entry are owned
119+
// by different packages and reached by different code paths. This is the
120+
// assertion that goes red when one of them is reworded alone.
121+
for (const [locale, nav] of [
122+
['zh-CN', zhCN],
123+
['ja-JP', jaJP],
124+
['es-ES', esES],
125+
] as const) {
126+
const navLabel = navApprovalsLabel(nav);
127+
expect(navLabel, `platform-objects lost the ${locale} nav_account_approvals label`).toBeTruthy();
128+
expect(resolvedMyPendingLabel(locale), `${locale} view/nav wording diverged`).toBe(navLabel);
129+
}
130+
});
131+
});

packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,11 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
4141
label: "Status",
4242
help: "Lifecycle state of the request",
4343
options: {
44-
pending: "pending",
45-
approved: "approved",
46-
rejected: "rejected",
47-
recalled: "recalled",
48-
returned: "returned"
44+
pending: "Pending",
45+
approved: "Approved",
46+
rejected: "Rejected",
47+
recalled: "Recalled",
48+
returned: "Returned"
4949
}
5050
},
5151
current_step: {

packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
8787
},
8888
_views: {
8989
my_pending: {
90-
label: "Mis pendientes",
90+
label: "Aprobaciones pendientes",
9191
emptyState: {
9292
title: "Sin aprobaciones pendientes",
9393
message: "Estás al día: nada espera tu aprobación."

packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
8787
},
8888
_views: {
8989
my_pending: {
90-
label: "自分の保留中",
90+
label: "承認待ち",
9191
emptyState: {
9292
title: "承認待ちはありません",
9393
message: "すべて処理済みです。あなたの承認を待つリクエストはありません。"

packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
4141
label: "状态",
4242
help: "请求的生命周期状态",
4343
options: {
44-
pending: "待处理",
44+
pending: "待审批",
4545
approved: "已批准",
4646
rejected: "已拒绝",
4747
recalled: "已撤回",
@@ -87,9 +87,9 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
8787
},
8888
_views: {
8989
my_pending: {
90-
label: "我的待办",
90+
label: "待我审批",
9191
emptyState: {
92-
title: "暂无待办审批",
92+
title: "暂无待审批的请求",
9393
message: "全部处理完毕,没有等待你审批的请求。"
9494
}
9595
},

0 commit comments

Comments
 (0)