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: "全部处理完毕,没有等待你审批的请求。" } },