From 3c7339443240d3d94f44d79c1e27bd87a9058e01 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 00:48:17 +0000 Subject: [PATCH 1/3] fix(cli): i18n coverage asks for an approval flow's label and each approval step's label The extractor demanded `flows.FLOW.label` only on a flow with a screen node and never walked approval nodes, so `os lint` and `os i18n check` never listed an approval flow or its steps as untranslated, though the approvals door reads `flows.FLOW.label` and `flows.FLOW.approvals.NODE.label` in the reader's locale. The label predicate now mirrors both readers (a screen node or an approval node); a flow with neither still owes no label. Each approval node with an id emits `flows.FLOW.approvals.NODE.label`, seeded with its authored label. Claude-Session: https://claude.ai/code/session_01AFrKwndmrCLtF5V9hNtrw3 Co-authored-by: Claude --- packages/cli/src/utils/i18n-coverage.ts | 10 +- packages/cli/src/utils/i18n-extract.ts | 52 ++++-- .../test/i18n-flow-approval-coverage.test.ts | 173 ++++++++++++++++++ .../test/i18n-flow-screen-coverage.test.ts | 15 +- 4 files changed, 228 insertions(+), 22 deletions(-) create mode 100644 packages/cli/test/i18n-flow-approval-coverage.test.ts diff --git a/packages/cli/src/utils/i18n-coverage.ts b/packages/cli/src/utils/i18n-coverage.ts index 857fa573e3f..01cc1fac793 100644 --- a/packages/cli/src/utils/i18n-coverage.ts +++ b/packages/cli/src/utils/i18n-coverage.ts @@ -359,13 +359,15 @@ const COVERAGE_SOURCE: Record // `.description` (#22507), and the per-field `label` / `placeholder` / // option labels), the terminal // toasts (`flows..successMessage` / `.errorMessage`, #22507) and a refused - // `end` node's message (`flows..refusals..message`, #22450) — the - // author's own text, so + // `end` node's message (`flows..refusals..message`, #22450), and an + // approval flow's label with each `approval` node's step label + // (`flows..approvals..label`, #24916) — the author's own text, so // it keeps its own bucket and reports as `i18n/missing-flow` rather than // folding away with `--include-platform`. Until this bucket existed the // family could not report a screen-flow gap at all: HotCRM measured // `0 i18n/missing-*` while six screen dialogs rendered English in all four - // locales (#11485). + // locales (#11485). Its six approval flows stayed that invisible until + // #24916. flow: 'flow', metadataType: 'metadataForm', metadataFormSection: 'metadataForm', @@ -429,7 +431,7 @@ const SOURCE_SURFACE: Record = { dataset: 'datasets', picklist: 'picklists', page: 'pages', - flow: 'flow screens, toasts and refusals', + flow: 'flow screens, approval steps, toasts and refusals', metadataForm: 'metadata forms', }; diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index f0ff87ab074..46641d01e87 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -113,6 +113,7 @@ * flows..screens..fields..options. (#22507) * flows..successMessage / .errorMessage (#22507) * flows..refusals..message (#22450) + * flows..approvals..label (#24916) * ^ walked because the ledger's `flows` row is `live` and warns no * author; a group the ledger does warn on is left out of the walk * (see `authorWarnedTranslationGroups`) @@ -143,7 +144,7 @@ import { pageTabItemKey, walkAddressedPageComponents, } from '@objectstack/spec/system'; -import { FLOW_REGION_SLOTS_BY_TYPE, flowScreenFieldOptionKey } from '@objectstack/spec/automation'; +import { APPROVAL_NODE_TYPE, FLOW_REGION_SLOTS_BY_TYPE, flowScreenFieldOptionKey } from '@objectstack/spec/automation'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { deriveFieldGroupLayout } from '@objectstack/spec/data'; import { expandViewContainer, InlineLocaleMapSchema } from '@objectstack/spec/ui'; @@ -1910,18 +1911,28 @@ function walkScreenFlows(config: any, out: ExpectedEntry[]): void { // by the schema, so this is authored text in practice; `pushOptional` // keeps a label-less flow from seeding an empty string anyway. // - // Demanded only for a flow with a `screen` node, at any depth of the same - // node universe the screens walk below reads. The predicate mirrors the one - // reader of the key: the console's `FlowRunner` names the flow by it, in - // its header and its completion toast, and the runner opens only on a run - // paused at a screen. A flow that can never pause there (record-triggered, - // scheduled, an API flow with no screen) has no surface that shows its - // translated label, so demanding one would ask an author for a string that - // is stored and never read. When a reader of a non-screen flow's label - // lands (a run-result toast, say), this predicate widens in the same change - // as that reader. + // Demanded for a flow with a `screen` node or an `approval` node, at any + // depth of the same node universe the walks below read. The predicate + // mirrors the two readers of the key, and nothing wider: + // + // - the console's `FlowRunner` names the flow by it, in its header and its + // completion toast, and the runner opens only on a run paused at a screen; + // - [#24916] the approvals door names a request's flow by it in the + // reader's locale (`plugin-approvals` `reader-locale-labels.ts`, the + // inbox, the record's approval panel and the request page), and a request + // exists only where an `approval` node opened one. + // + // A flow with neither (record-triggered, scheduled or an API flow that + // only does work) has no surface that shows its translated label: the + // console's launch hosts toast the action's copy or the flow's own + // `successMessage` / `errorMessage`, never its label. Demanding one would + // ask an author for a string that is stored and never read, so `Flow.label` + // being required by the schema is not the test. When a reader of such a + // flow's label lands, this predicate widens in the same change as that + // reader. const hasScreen = nodes.some((node) => node && typeof node === 'object' && node.type === SCREEN_NODE_TYPE); - if (hasScreen) { + const approvalNodes = nodes.filter((node) => node && typeof node === 'object' && node.type === APPROVAL_NODE_TYPE); + if (hasScreen || approvalNodes.length > 0) { pushOptional(out, ['flows', flowName, 'label'], flow.label, 'flow', scope); } @@ -2008,6 +2019,23 @@ function walkScreenFlows(config: any, out: ExpectedEntry[]): void { if (!nodeId) continue; pushOptional(out, ['flows', flowName, 'refusals', nodeId, 'message'], inlineText(cfg.message), 'flow', scope); } + + // [#24916] `flows..approvals..label` — an `approval` node's + // step label, the name its request shows the submitter and the approvers. + // The approvals door reads that key in the reader's locale for the + // request's `current_step`, which is the node's `FlowNode.id`, and its step + // progress names every approval step the same way; the stored + // `current_step_label` is the authored `FlowNode.label` the seed carries. + // The key's one leaf is `label` because the node config declares no + // person-facing heading of its own (`translation.zod.ts`, `approvals`). + // Same node universe as the walks above, so the walk reaches every id + // `os validate` accepts the key for. + for (const node of approvalNodes) { + const nodeId = typeof node.id === 'string' && node.id.length > 0 ? node.id : undefined; + // No id, no key: the request's `current_step` could not name the node. + if (!nodeId) continue; + pushOptional(out, ['flows', flowName, 'approvals', nodeId, 'label'], node.label, 'flow', scope); + } } } diff --git a/packages/cli/test/i18n-flow-approval-coverage.test.ts b/packages/cli/test/i18n-flow-approval-coverage.test.ts new file mode 100644 index 00000000000..1811db7c2fc --- /dev/null +++ b/packages/cli/test/i18n-flow-approval-coverage.test.ts @@ -0,0 +1,173 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// objectstack#24916 — an approval flow's label and each approval step's label +// join the flow bucket. +// +// The approvals door names a request's flow and its current step in the +// READER's locale (`plugin-approvals` `reader-locale-labels.ts`), from +// `flows..label` and `flows..approvals..label`. The walker +// asked for the flow label only on a flow with a `screen` node, and for no +// approval step at all, so `os lint` and `os i18n check` never listed an +// approval flow as untranslated: HotCRM's six approval flows rendered English +// in zh-CN / ja-JP / es-ES under a green coverage run. These pins are the CLI +// half: the walker scaffolds both keys, the coverage gate demands them, and a +// flow no reader names (no screen, no approval) still owes no label. +// +// The ledger mock is `i18n-flow-screen-coverage.test.ts`'s, for its reason: +// these pins measure the bucket itself, whatever the shipped ledger says next. + +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('@objectstack/lint', async (importOriginal) => ({ + ...(await importOriginal()), + authorWarnedProperties: () => new Set(), +})); + +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; +import { TranslationDataSchema } from '@objectstack/spec/system'; + +/** An approval-only flow: record-triggered, two approval steps, no screen. */ +const expenseApproval = { + name: 'expense_approval', + label: 'Expense Approval', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'On Expense Created', config: { objectName: 'expense', triggerType: 'record-after-insert' } }, + { id: 'manager_review', type: 'approval', label: 'Manager Review', config: { approvers: [{ type: 'org_membership_level', value: 'owner' }] } }, + { id: 'finance_review', type: 'approval', label: 'Finance Review', config: { approvers: [{ type: 'org_membership_level', value: 'owner' }] } }, + { id: 'approved', type: 'end', label: 'Approved' }, + ], + edges: [], +}; + +/** A screen flow, the shape the bucket already covered. */ +const quickNote = { + name: 'quick_note', + label: 'Quick Note', + type: 'screen', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'ask', type: 'screen', label: 'Ask', config: { title: 'Write a note', fields: [{ name: 'note', label: 'Note' }] } }, + { id: 'done', type: 'end', label: 'Done' }, + ], + edges: [], +}; + +/** A scheduled flow with no screen and no approval: no reader names it. */ +const nightlyDigest = { + name: 'nightly_digest', + label: 'Nightly Digest', + type: 'schedule', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'mail', type: 'send_email', label: 'Mail' }, + ], + edges: [], +}; + +const flowKeys = (config: any) => + collectExpectedEntries(config).filter((e) => e.path[0] === 'flows').map((e) => e.path.join('.')); + +describe('what the walker harvests from an approval flow (#24916)', () => { + it('emits the flow label and each approval step label, seeded with the authored text', () => { + const entries = collectExpectedEntries({ flows: [expenseApproval] }).filter((e) => e.path[0] === 'flows'); + + expect(entries.map((e) => e.path.join('.'))).toEqual([ + 'flows.expense_approval.label', + 'flows.expense_approval.approvals.manager_review.label', + 'flows.expense_approval.approvals.finance_review.label', + ]); + expect(entries[0]).toMatchObject({ sourceValue: 'Expense Approval', inline: 'Expense Approval', source: 'flow', flowName: 'expense_approval' }); + expect(entries[1]).toMatchObject({ sourceValue: 'Manager Review', inline: 'Manager Review', source: 'flow', flowName: 'expense_approval' }); + }); + + it('skips an approval node with no id: the request could not name it', () => { + const flow = { ...expenseApproval, nodes: [{ type: 'approval', label: 'Anonymous Review' }] }; + expect(flowKeys({ flows: [flow] })).toEqual(['flows.expense_approval.label']); + }); + + it('still emits no label for a flow with neither a screen nor an approval node', () => { + expect(flowKeys({ flows: [nightlyDigest] })).toEqual([]); + }); +}); + +describe('the coverage gate demands the approval flow and its steps (#24916)', () => { + const tree = (flows?: Record) => ({ + i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] }, + flows: [expenseApproval, quickNote, nightlyDigest], + translations: [{ 'zh-CN': { ...(flows ? { flows } : {}) } }], + }); + const zhFlowIssues = (config: any) => + computeI18nCoverage(config).issues.filter((i) => i.locale === 'zh-CN' && i.source === 'flow'); + + it('lists the untranslated approval flow label and step labels in the flow bucket', () => { + const keys = zhFlowIssues(tree()).map((i) => i.key).filter((k) => k.startsWith('flows.expense_approval.')); + expect(keys).toEqual([ + 'flows.expense_approval.label', + 'flows.expense_approval.approvals.manager_review.label', + 'flows.expense_approval.approvals.finance_review.label', + ]); + }); + + it('keeps the step labels listed while only the flow label is translated', () => { + const keys = zhFlowIssues(tree({ expense_approval: { label: '报销审批' } })) + .map((i) => i.key) + .filter((k) => k.startsWith('flows.expense_approval.')); + expect(keys).toEqual([ + 'flows.expense_approval.approvals.manager_review.label', + 'flows.expense_approval.approvals.finance_review.label', + ]); + }); + + it('goes quiet for the approval flow once its label and every step are translated', () => { + const keys = zhFlowIssues(tree({ + expense_approval: { + label: '报销审批', + approvals: { manager_review: { label: '经理审核' }, finance_review: { label: '财务审核' } }, + }, + })).map((i) => i.key); + expect(keys.filter((k) => k.startsWith('flows.expense_approval.'))).toEqual([]); + }); + + it('keeps the screen flow\'s demand exactly as it was, and asks nothing of the scheduled flow', () => { + const keys = zhFlowIssues(tree()).map((i) => i.key); + expect(keys.filter((k) => k.startsWith('flows.quick_note.'))).toEqual([ + 'flows.quick_note.label', + 'flows.quick_note.screens.ask.title', + 'flows.quick_note.screens.ask.fields.note.label', + ]); + expect(keys.filter((k) => k.startsWith('flows.nightly_digest.'))).toEqual([]); + }); + + it('accepts the translated pack the way the translation contract declares it', () => { + // The key the gate asks for is one the face declares: the translation that + // silences it parses with no unrecognized key. + const parsed = TranslationDataSchema.safeParse({ + flows: { expense_approval: { label: '报销审批', approvals: { manager_review: { label: '经理审核' } } } }, + }); + expect(parsed.success ? [] : parsed.error.issues).toEqual([]); + }); +}); + +describe('the skeleton `os i18n extract` writes for an approval flow (#24916)', () => { + const result = extractTranslations({ flows: [expenseApproval] }, { locales: ['en', 'zh-CN'] }); + + it('writes the authored labels into the default locale and empty slots into the others', () => { + expect((result.bundles.en as any).flows.expense_approval).toEqual({ + label: 'Expense Approval', + approvals: { manager_review: { label: 'Manager Review' }, finance_review: { label: 'Finance Review' } }, + }); + expect((result.bundles['zh-CN'] as any).flows.expense_approval).toEqual({ + label: '', + approvals: { manager_review: { label: '' }, finance_review: { label: '' } }, + }); + }); + + it('parses through the schema it is written for, in every locale', () => { + for (const [locale, bundle] of Object.entries(result.bundles)) { + const parsed = TranslationDataSchema.safeParse(bundle); + expect(parsed.success ? [] : parsed.error.issues, `skeleton for ${locale}`).toEqual([]); + } + }); +}); diff --git a/packages/cli/test/i18n-flow-screen-coverage.test.ts b/packages/cli/test/i18n-flow-screen-coverage.test.ts index 8dbebc60b61..c592480b9ef 100644 --- a/packages/cli/test/i18n-flow-screen-coverage.test.ts +++ b/packages/cli/test/i18n-flow-screen-coverage.test.ts @@ -608,12 +608,15 @@ describe('a screen inside an ADR-0031 region (#17511)', () => { // ── The flow's own label is demanded only for a flow the runner can open ──── // -// The console's `FlowRunner` is the one reader of `flows..label`: it -// names the flow in its header and its completion toast, and it opens only on -// a run paused at a screen node. So the walker emits the label entry only for -// a flow with a screen node somewhere in the node universe the screens walk -// reads, at any depth. Both sides are pinned: a flow with no screen node owes -// no label, and a flow whose only screen sits inside a region still owes it. +// The console's `FlowRunner` reads `flows..label`: it names the flow in +// its header and its completion toast, and it opens only on a run paused at a +// screen node. So the walker emits the label entry for a flow with a screen +// node somewhere in the node universe the screens walk reads, at any depth. +// Both sides are pinned: a flow with no screen node owes no label, and a flow +// whose only screen sits inside a region still owes it. The approvals door is +// the key's other reader, so a flow with an `approval` node owes it too +// (#24916, pinned in `i18n-flow-approval-coverage.test.ts`); the scheduled +// flow below has neither. /** A scheduled flow whose nodes include a region, and no screen at any depth. */ const nightlyDigest = { From 7ed34f9d47548fcad2f36f28335b2920304a8113 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 01:00:26 +0000 Subject: [PATCH 2/3] chore(example-showcase): translate the approval flows and steps the widened i18n coverage now asks for; changeset for the cli fix `check:i18n-coverage` measured app-showcase at 437 against its baselined 414 once the extractor asked for approval flow labels and approval step labels: eight flow labels and fifteen steps, all zh-CN. They are translated here, as the gate prescribes, so the baseline stays at 414 rather than freezing new debt. Claude-Session: https://claude.ai/code/session_01AFrKwndmrCLtF5V9hNtrw3 Co-authored-by: Claude --- .../24916-approval-flow-i18n-coverage.md | 29 +++++++++++++ .../src/system/translations/index.ts | 42 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 .changeset/24916-approval-flow-i18n-coverage.md diff --git a/.changeset/24916-approval-flow-i18n-coverage.md b/.changeset/24916-approval-flow-i18n-coverage.md new file mode 100644 index 00000000000..0791cb41f05 --- /dev/null +++ b/.changeset/24916-approval-flow-i18n-coverage.md @@ -0,0 +1,29 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): `os lint` and `os i18n check` ask for an approval flow's label and each approval step's label (#24916) + +Clause-②: no +Metadata-protocol: none + +The approvals door names a request's flow and its current step in the reader's locale, from `flows.FLOW.label` and `flows.FLOW.approvals.NODE.label`. The i18n extractor asked for `flows.FLOW.label` only on a flow with a `screen` node, and for no approval step at all, so `i18n/missing-flow` never listed an approval flow or its steps as untranslated, and `os i18n extract` never scaffolded those keys. An app's approvals inbox could show the authoring language in every other locale under a green coverage run. + +Now: + +- `flows.FLOW.label` is asked for on a flow with a `screen` node or an `approval` node, the two surfaces that show it. A flow with neither, such as a record-triggered or scheduled flow that only does work, still owes no label, because nothing shows it. +- `flows.FLOW.approvals.NODE.label` is asked for on every `approval` node that has an `id`, seeded with the node's own `label`. +- Both are reported as `i18n/missing-flow` by `os lint`, listed by `os i18n check`, and written into the skeleton by `os i18n extract`. `os i18n check --help` names approval steps among the surfaces it covers. + +**What you may see.** An app that ships an approval flow and declares a locale beyond its default now gets an `i18n/missing-flow` finding for each untranslated approval flow and step. Under `os lint --i18n-strict` or `os i18n check --strict` these are errors, like every other missing translation. To clear them, translate the keys in that locale's pack, for example: + +```ts +flows: { + expense_approval: { + label: '报销审批', + approvals: { manager_review: { label: '经理审核' } }, + }, +}, +``` + +`os i18n extract` writes the empty slots for you and keeps the strings a pack already translates. No key, schema or export changes: both keys were already part of the translation contract, and `os validate` already accepted them. diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index 955165bd6e4..030a32adae4 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -1127,6 +1127,48 @@ export const ShowcaseTranslationBundle = { }, }, }, + // The approval flows: the flow's name and each approval step's name, as + // the approvals inbox, the record's approval panel and the request page + // show them in the reader's locale. + showcase_budget_approval: { + label: '项目预算审批', + approvals: { manager_review: { label: '经理审核' }, exec_review: { label: '高管审核' } }, + }, + showcase_closure_signoff: { + label: '结项签批(审批子流程)', + approvals: { ask_signoff: { label: '经理签批' } }, + }, + showcase_invoice_signoff: { + label: '发票双重签批(并行审批)', + approvals: { dual_signoff: { label: '财务 + 法务签批' } }, + }, + showcase_one_task_signoff: { + label: '单个任务签批(逐项子流程)', + approvals: { review: { label: '任务签批' } }, + }, + showcase_expense_signoff: { + label: '费用报告签批', + approvals: { dual_signoff: { label: '经理 + 财务签批' } }, + }, + showcase_committee_quorum: { + label: '大额费用 — 委员会多数签批', + approvals: { committee: { label: '委员会签批(三分之二)' } }, + }, + showcase_dynamic_approval: { + label: '公告动态审批', + approvals: { lead_review: { label: '负责人审核' }, co_sign: { label: '会签' } }, + }, + showcase_approver_bindings: { + label: '审批人绑定示例(#3508)', + approvals: { + by_user: { label: '指定用户' }, + by_team: { label: '团队成员' }, + by_department: { label: '部门成员' }, + by_position: { label: '岗位任职者' }, + by_manager: { label: '提交人的经理' }, + by_field: { label: '触发字段中的用户' }, + }, + }, }, }, }; From 0a4800d41835ab680708e0c8d5b361464f3100db Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 01:03:44 +0000 Subject: [PATCH 3/3] refactor(cli): import APPROVAL_NODE_TYPE on its own line, clear of the automation import an in-flight PR rewrites Claude-Session: https://claude.ai/code/session_01AFrKwndmrCLtF5V9hNtrw3 Co-authored-by: Claude --- packages/cli/src/utils/i18n-extract.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index 46641d01e87..b744d4c7bc4 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -144,9 +144,10 @@ import { pageTabItemKey, walkAddressedPageComponents, } from '@objectstack/spec/system'; -import { APPROVAL_NODE_TYPE, FLOW_REGION_SLOTS_BY_TYPE, flowScreenFieldOptionKey } from '@objectstack/spec/automation'; +import { FLOW_REGION_SLOTS_BY_TYPE, flowScreenFieldOptionKey } from '@objectstack/spec/automation'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import { deriveFieldGroupLayout } from '@objectstack/spec/data'; +import { APPROVAL_NODE_TYPE } from '@objectstack/spec/automation'; import { expandViewContainer, InlineLocaleMapSchema } from '@objectstack/spec/ui'; import { authorWarnedProperties, walkPageComponents } from '@objectstack/lint'; import { collectFilledFromHashes } from '@objectstack/platform-objects/apps';