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
29 changes: 29 additions & 0 deletions .changeset/24916-approval-flow-i18n-coverage.md
Original file line number Diff line number Diff line change
@@ -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.
42 changes: 42 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '触发字段中的用户' },
},
},
},
},
};
10 changes: 6 additions & 4 deletions packages/cli/src/utils/i18n-coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,13 +359,15 @@ const COVERAGE_SOURCE: Record<ExpectedEntry['source'], CoverageIssue['source']>
// `.description` (#22507), and the per-field `label` / `placeholder` /
// option labels), the terminal
// toasts (`flows.<f>.successMessage` / `.errorMessage`, #22507) and a refused
// `end` node's message (`flows.<f>.refusals.<n>.message`, #22450) — the
// author's own text, so
// `end` node's message (`flows.<f>.refusals.<n>.message`, #22450), and an
// approval flow's label with each `approval` node's step label
// (`flows.<f>.approvals.<n>.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',
Expand Down Expand Up @@ -429,7 +431,7 @@ const SOURCE_SURFACE: Record<CoverageIssue['source'], string> = {
dataset: 'datasets',
picklist: 'picklists',
page: 'pages',
flow: 'flow screens, toasts and refusals',
flow: 'flow screens, approval steps, toasts and refusals',
metadataForm: 'metadata forms',
};

Expand Down
51 changes: 40 additions & 11 deletions packages/cli/src/utils/i18n-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@
* flows.<flow>.screens.<node_id>.fields.<field>.options.<value> (#22507)
* flows.<flow>.successMessage / .errorMessage (#22507)
* flows.<flow>.refusals.<node_id>.message (#22450)
* flows.<flow>.approvals.<node_id>.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`)
Expand Down Expand Up @@ -146,6 +147,7 @@ import {
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';
Expand Down Expand Up @@ -1910,18 +1912,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);
}

Expand Down Expand Up @@ -2008,6 +2020,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.<flow>.approvals.<node_id>.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);
}
}
}

Expand Down
173 changes: 173 additions & 0 deletions packages/cli/test/i18n-flow-approval-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -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.<flow>.label` and `flows.<flow>.approvals.<node_id>.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<typeof import('@objectstack/lint')>()),
authorWarnedProperties: () => new Set<string>(),
}));

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<string, unknown>) => ({
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([]);
}
});
});
15 changes: 9 additions & 6 deletions packages/cli/test/i18n-flow-screen-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<flow>.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.<flow>.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 = {
Expand Down
Loading