From 3e25b22d752b56de5859bfc387800af8f0d1014f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 04:14:47 +0000 Subject: [PATCH] fix(plugin-approvals): unify the approval-status vocabulary across the i18n bundles (#7232) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01L9U1G2piXmYrhYQX96XUyv --- .changeset/hip-planes-shake.md | 20 +++ .../approval-status-vocabulary.test.ts | 131 ++++++++++++++++++ .../src/translations/en.objects.generated.ts | 10 +- .../translations/es-ES.objects.generated.ts | 2 +- .../translations/ja-JP.objects.generated.ts | 2 +- .../translations/zh-CN.objects.generated.ts | 6 +- 6 files changed, 161 insertions(+), 10 deletions(-) create mode 100644 .changeset/hip-planes-shake.md create mode 100644 packages/plugins/plugin-approvals/src/translations/approval-status-vocabulary.test.ts diff --git a/.changeset/hip-planes-shake.md b/.changeset/hip-planes-shake.md new file mode 100644 index 0000000000..2f48fd174d --- /dev/null +++ b/.changeset/hip-planes-shake.md @@ -0,0 +1,20 @@ +--- +'@objectstack/plugin-approvals': patch +--- + +Unify the approval-status vocabulary across the `sys_approval_request` i18n bundles (#7232). + +A request rendered through the generic object surfaces used a different word than the same +request in the Approvals Inbox and in the account-app navigation. The bundles now say what +those surfaces already say: + +- **zh-CN**: the `status` option `pending` reads 待审批 (was 待处理), and the `my_pending` + view reads 待我审批 (was 我的待办), matching the account-app nav entry; the view's + empty-state title was aligned to the same wording. +- **en**: the `status` options are humanized — `Pending` / `Approved` / `Rejected` / + `Recalled` / `Returned` — instead of shipping the raw enum values as labels. +- **ja-JP / es-ES**: the `my_pending` view label now matches the nav wording (承認待ち / + Aprobaciones pendientes). + +Status **values** are unchanged — this is display wording only, so no stored data, filter, +or API payload is affected. diff --git a/packages/plugins/plugin-approvals/src/translations/approval-status-vocabulary.test.ts b/packages/plugins/plugin-approvals/src/translations/approval-status-vocabulary.test.ts new file mode 100644 index 0000000000..9943506988 --- /dev/null +++ b/packages/plugins/plugin-approvals/src/translations/approval-status-vocabulary.test.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Approval-status vocabulary pin (#7232 — problem 4 of #7213: "three +// vocabularies for one request"). +// +// `sys_approval_request` is rendered by three unrelated surfaces — the generic +// object views driven by this plugin's bundles, the Approvals Inbox (objectui +// `approvalsInbox.*`), and the account-app navigation owned by +// `@objectstack/platform-objects`. They share no keys, so nothing made them +// agree, and they did not: zh said 待处理 for the status and 我的待办 for the +// `my_pending` view while the other two said 待审批 / 待我审批, and the en bundle +// shipped the raw enum values (`pending`, `approved`, …) as labels. +// +// What this file pins is the AGREEMENT, not the file contents. Two properties +// it would be easy to assert and worthless to: +// +// • Re-reading `zhCNObjects.…options.pending` and expecting 待审批 restates +// the line it is guarding. Every assertion here goes through the real +// resolver (`translateObject` / `resolveViewLabel`) against the real +// `SysApprovalRequest`, so it also proves the bundle is REACHED — the +// declared option label is the bare enum value (see the guard-the-guard +// case), so a bundle that stopped being consulted would surface here as +// raw values rather than as a silent pass. +// +// • Pinning each locale in isolation lets the layers drift apart again, which +// is the whole defect. The parity case compares this plugin's `my_pending` +// view label against the account-app nav label in platform-objects, so +// moving either side alone goes red. +// +// en is deliberately NOT in the parity set: its nav entry reads "Approvals" +// (the destination) while the view reads "My Pending" (the filter), and #7232's +// glossary keeps that split. Only the locales whose two layers say the same +// thing are pinned to keep saying it. + +import { describe, it, expect } from 'vitest'; +import { APPROVAL_STATUSES } from '@objectstack/spec/contracts'; +import { translateObject, resolveViewLabel } from '@objectstack/spec/system'; +import type { TranslationData } from '@objectstack/spec/system'; +import { zhCN, jaJP, esES } from '@objectstack/platform-objects/apps'; + +import { ApprovalsTranslations } from './index.js'; +import { SysApprovalRequest } from '../sys-approval-request.object.js'; + +/** Status option labels as a consumer sees them after i18n resolution. */ +const resolvedStatusLabels = (locale: string): Record => { + const doc = translateObject(SysApprovalRequest as any, ApprovalsTranslations, { locale }); + const options = (doc as any)?.fields?.status?.options as + | Array<{ value: string; label?: string }> + | undefined; + return Object.fromEntries((options ?? []).map((o) => [o.value, o.label ?? ''])); +}; + +const resolvedMyPendingLabel = (locale: string): string => + resolveViewLabel(ApprovalsTranslations, (SysApprovalRequest as any).listViews.my_pending, { + locale, + }); + +const navApprovalsLabel = (data: TranslationData): string | undefined => + (data as any)?.apps?.account?.navigation?.nav_account_approvals?.label; + +describe('approval status vocabulary (#7232)', () => { + it('guard the guard: the DECLARED option label is the bare enum value', () => { + // `Field.select([...APPROVAL_STATUSES])` normalizes each bare string to + // `{ label: 'pending', value: 'pending' }` — the label IS the value. Every + // humanized label below therefore comes from the bundle and nowhere else; + // without this case a resolver that silently stopped consulting the bundle + // could still satisfy an en expectation of "pending". + const declared = (SysApprovalRequest as any).fields.status.options as Array<{ + value: string; + label?: string; + }>; + expect(declared.length).toBe(APPROVAL_STATUSES.length); + expect(declared.map((o) => o.label)).toEqual(declared.map((o) => o.value)); + }); + + it('en humanizes every status instead of shipping the raw enum value', () => { + expect(resolvedStatusLabels('en')).toEqual({ + pending: 'Pending', + approved: 'Approved', + rejected: 'Rejected', + recalled: 'Recalled', + returned: 'Returned', + }); + }); + + it('zh-CN says 待审批 for pending — the Approvals Inbox wording', () => { + expect(resolvedStatusLabels('zh-CN')).toEqual({ + pending: '待审批', + approved: '已批准', + rejected: '已拒绝', + recalled: '已撤回', + returned: '已退回修改', + }); + }); + + it('no locale leaks a raw enum value as a status label', () => { + // Ratchet for statuses and locales added later: a new entry that reaches a + // bundle un-translated is seeded from the source text, which is the enum + // value itself, so this case catches it without naming it. + for (const locale of ['en', 'zh-CN', 'ja-JP', 'es-ES']) { + const labels = resolvedStatusLabels(locale); + expect(Object.keys(labels).sort()).toEqual([...APPROVAL_STATUSES].sort()); + const raw = Object.entries(labels) + .filter(([value, label]) => value === label) + .map(([value]) => value); + expect(raw, `${locale} renders these statuses as their raw enum value`).toEqual([]); + } + }); + + it('the my_pending view carries the per-locale glossary wording', () => { + expect(resolvedMyPendingLabel('en')).toBe('My Pending'); + expect(resolvedMyPendingLabel('zh-CN')).toBe('待我审批'); + expect(resolvedMyPendingLabel('ja-JP')).toBe('承認待ち'); + expect(resolvedMyPendingLabel('es-ES')).toBe('Aprobaciones pendientes'); + }); + + it('the my_pending view label matches the account-app nav label (problem 4)', () => { + // The cross-layer half: the object view and the navigation entry are owned + // by different packages and reached by different code paths. This is the + // assertion that goes red when one of them is reworded alone. + for (const [locale, nav] of [ + ['zh-CN', zhCN], + ['ja-JP', jaJP], + ['es-ES', esES], + ] as const) { + const navLabel = navApprovalsLabel(nav); + expect(navLabel, `platform-objects lost the ${locale} nav_account_approvals label`).toBeTruthy(); + expect(resolvedMyPendingLabel(locale), `${locale} view/nav wording diverged`).toBe(navLabel); + } + }); +}); diff --git a/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts index 1263db7129..f526fc7027 100644 --- a/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts @@ -41,11 +41,11 @@ export const enObjects: NonNullable = { label: "Status", help: "Lifecycle state of the request", options: { - pending: "pending", - approved: "approved", - rejected: "rejected", - recalled: "recalled", - returned: "returned" + pending: "Pending", + approved: "Approved", + rejected: "Rejected", + recalled: "Recalled", + returned: "Returned" } }, current_step: { diff --git a/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts index 4537c54bf6..a535ed3e63 100644 --- a/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts @@ -87,7 +87,7 @@ export const esESObjects: NonNullable = { }, _views: { my_pending: { - label: "Mis pendientes", + label: "Aprobaciones pendientes", emptyState: { title: "Sin aprobaciones pendientes", message: "Estás al día: nada espera tu aprobación." diff --git a/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts index b82258128c..fe0295140e 100644 --- a/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts @@ -87,7 +87,7 @@ export const jaJPObjects: NonNullable = { }, _views: { my_pending: { - label: "自分の保留中", + label: "承認待ち", emptyState: { title: "承認待ちはありません", message: "すべて処理済みです。あなたの承認を待つリクエストはありません。" diff --git a/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts index e847e086cd..5e8f2a6445 100644 --- a/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts @@ -41,7 +41,7 @@ export const zhCNObjects: NonNullable = { label: "状态", help: "请求的生命周期状态", options: { - pending: "待处理", + pending: "待审批", approved: "已批准", rejected: "已拒绝", recalled: "已撤回", @@ -87,9 +87,9 @@ export const zhCNObjects: NonNullable = { }, _views: { my_pending: { - label: "我的待办", + label: "待我审批", emptyState: { - title: "暂无待办审批", + title: "暂无待审批的请求", message: "全部处理完毕,没有等待你审批的请求。" } },