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
20 changes: 20 additions & 0 deletions .changeset/hip-planes-shake.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<string, string> => {
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);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
},
_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."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
},
_views: {
my_pending: {
label: "自分の保留中",
label: "承認待ち",
emptyState: {
title: "承認待ちはありません",
message: "すべて処理済みです。あなたの承認を待つリクエストはありません。"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
label: "状态",
help: "请求的生命周期状态",
options: {
pending: "待处理",
pending: "待审批",
approved: "已批准",
rejected: "已拒绝",
recalled: "已撤回",
Expand Down Expand Up @@ -87,9 +87,9 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
},
_views: {
my_pending: {
label: "我的待办",
label: "待我审批",
emptyState: {
title: "暂无待办审批",
title: "暂无待审批的请求",
message: "全部处理完毕,没有等待你审批的请求。"
}
},
Expand Down
Loading