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
7 changes: 7 additions & 0 deletions .changeset/20318-cli-flow-label-demand.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@objectstack/cli": patch
---

`os lint` and `os i18n extract` ask for a flow's `flows.<flow>.label` translation only when the flow has a screen node at any depth, the only kind of flow the console's screen-flow runner opens and names, so a scheduled, record-triggered or API flow with no screen no longer draws an `i18n/missing-flow` demand for a label no surface shows.

Clause-②: no
12 changes: 12 additions & 0 deletions .changeset/20318-flows-translation-live.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@objectstack/spec": patch
---

Liveness ledger: the `flows` translation group is `live`, and so are both its children. The console's screen-flow runner now names the flow by `flows.<flow>.label` in the active language, in the runner's header and in its completion toast. A locale the bundle does not cover shows the label authored on the flow, and then the flow's API name. `screens` was already `live`.

Clause-②: no

- The `flows` row drops `authorWarn` and its `authorHint`. `os lint` and `os validate` no longer warn `liveness-planned-property` on a bundle that authors `flows`. A warning is not a refusal, so the accept set is unchanged.
- Dropping that bit switches on the CLI's i18n coverage demand for `flows.*`. `os lint` now reports a `flows.<flow>.*` key that a supported locale is missing as `i18n/missing-flow`, and `os i18n extract` scaffolds the group into the bundle. The flow's own `label` is demanded only for a flow with a screen node (see the `@objectstack/cli` entry). Under `--i18n-strict` a missing key is an error: translate it, or run `os i18n extract` to scaffold it.
- The `flows` TSDoc in `translation.zod.ts` and the translations guide's boundary note now say that both halves are applied.
- ⛔ No schema, parse, export or accept-set change.
45 changes: 24 additions & 21 deletions content/docs/ui/translations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -382,27 +382,30 @@ Honest limits worth knowing before you plan around them:
by rule name alone and so could not tell two objects' rules apart; the route
above is object-scoped and shipped with its reader. ADR-0049's 2026-09-04
amendment carries that record.
- **The `flows` group is only partly applied.** A screen flow's copy has
somewhere to live (#7646) and the keys are addressed the way the runner
resolves them — flow name, screen node id, screen field name. The console's
screen-flow runner reads `screens`: each screen's `title` and each field's
`label`, `placeholder` and `inlineHelpText` render in the active locale. The
flow's own `label` is read by nothing yet, so the liveness ledger carries the
group as `planned` and the compile lint warns when you author it. The runner's
own chrome — the Cancel and Submit buttons — is deliberately outside the
group: it belongs to the console's message catalog rather than your app's
bundle.

**So the tooling does not ask you for these keys either.** `os lint` does not
report `flows.*` as missing translations, and `os i18n extract` does not
scaffold them into your bundle — both read the same `planned` row. Without
that, the two halves of a single `os lint` run contradicted each other:
omitting the keys was reported as a coverage gap, and adding them was reported
as authoring a group nothing reads. If you author the copy anyway you get the
liveness warning and nothing else — it is telling you the truth, not asking
you to delete a key you will need later. The day the runner lands and the row
flips to `live`, both the coverage report and the extract skeleton pick the
group up on their own; there is no flag to turn on.
- **The `flows` group is applied by the console's screen-flow runner, and its
face is deliberately small.** The keys are addressed the way the runner
resolves them — flow name, screen node id, screen field name — and both
halves render in the active locale:
- `screens` is applied to the screen copy: each screen's `title`, and each
field's `label`, `placeholder` and `inlineHelpText`;
- the flow's own `label` names the flow in the runner's header, above the
step's heading, and in the completion toast. A locale the bundle does not
cover shows the label authored on the flow, and a backend that serves no
label shows the flow's API name.

Three strings stay outside the group. A screen's `description` renders as
authored. A `successMessage` authored on the flow replaces the completion
sentence and renders as authored too, so the translated label appears in the
toast only when the flow declares none. The runner's own chrome — the Cancel
and Submit buttons — belongs to the console's message catalog rather than your
app's bundle.

**The tooling asks for these keys like any other group's.** `os lint` reports
a missing `flows.*` key against `supportedLocales`, and `os i18n extract`
scaffolds the group into your bundle. A flow's own `label` is asked for only
when the flow has a screen node, at any depth, because the runner opens only
on a screen: a scheduled or record-triggered flow with no screen has no
surface that shows its translated label.
- **No ICU MessageFormat** — plural/gender formatting isn't available;
interpolation is always simple `{variable}` substitution.
- **Runtime authoring is process-wide.** The authored layer is synced across all
Expand Down
12 changes: 12 additions & 0 deletions examples/app-crm/src/translations/crm.translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,18 @@ export const CrmTranslationBundle = defineTranslationBundle({
},
},
},
// The lead-conversion wizard: the flow's name in the runner header and
// completion toast, and each screen's heading.
flows: {
crm_convert_lead_wizard: {
label: '将线索转化为客户和商机',
screens: {
screen_already_converted: { title: '已转化' },
screen_account: { title: '第 1 步,共 2 步 · 客户' },
screen_opportunity: { title: '第 2 步,共 2 步 · 商机' },
},
},
},
messages: {
'crm.lead.convert.success': '线索已成功转化为商机。',
'crm.lead.convert.error': '线索转化失败,请重试。',
Expand Down
13 changes: 13 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1088,5 +1088,18 @@ export const ShowcaseTranslationBundle = {
},
},
},
// The reassign wizard: the flow's name in the runner header and
// completion toast, its one screen's heading and its field label.
flows: {
showcase_reassign_wizard: {
label: '重新分配任务',
screens: {
collect: {
title: '新负责人',
fields: { new_assignee: { label: '新负责人' } },
},
},
},
},
},
};
19 changes: 19 additions & 0 deletions examples/app-todo/src/translations/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,25 @@ export const jaJP: TranslationData = {
description: '個人タスク管理アプリケーション',
},
},
// The quick-add wizard: the flow's name in the runner header and completion
// toast, each screen's heading and each field label.
flows: {
quick_add_task: {
label: 'タスクをすばやく追加',
screens: {
screen_1: {
title: 'タスクの詳細',
fields: {
subject: { label: 'タスクの件名' },
priority: { label: '優先度' },
dueDate: { label: '期日' },
category: { label: 'カテゴリ' },
},
},
success_screen: { title: 'タスクを作成しました' },
},
},
},
// Single-segment `messages` ids — `t()` walks the dot path, so an id
// containing a dot resolves to nothing; see the `en` bundle (#18566).
messages: {
Expand Down
19 changes: 19 additions & 0 deletions examples/app-todo/src/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,25 @@ export const zhCN: TranslationData = {
description: '个人任务管理应用',
},
},
// The quick-add wizard: the flow's name in the runner header and completion
// toast, each screen's heading and each field label.
flows: {
quick_add_task: {
label: '快速添加任务',
screens: {
screen_1: {
title: '任务详情',
fields: {
subject: { label: '任务主题' },
priority: { label: '优先级' },
dueDate: { label: '截止日期' },
category: { label: '分类' },
},
},
success_screen: { title: '任务已创建' },
},
},
},
// Single-segment `messages` ids — `t()` walks the dot path, so an id
// containing a dot resolves to nothing; see the `en` bundle (#18566).
messages: {
Expand Down
51 changes: 33 additions & 18 deletions packages/cli/src/utils/i18n-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,10 @@
* flows.<flow>.screens.<node_id>.title (#7646 / #11287)
* flows.<flow>.screens.<node_id>.fields.<field>.label
* flows.<flow>.screens.<node_id>.fields.<field>.placeholder
* ^ gated: `flows` is `planned` + `authorWarn` in the liveness ledger, so
* these are walked only once the row goes `live` (#11624 — see
* `authorWarnedTranslationGroups`)
* flows.<flow>.screens.<node_id>.fields.<field>.inlineHelpText
* ^ 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`)
* metadataForms.<type>.label / .description
* metadataForms.<type>.sections.<section>.label / .description
* metadataForms.<type>.fields.<dotPath>.label / .helpText / .placeholder
Expand Down Expand Up @@ -1067,8 +1068,9 @@ function walkObjectTabs(config: any, out: ExpectedEntry[]): void {
}

/**
* Translation groups the shipped liveness ledger warns an author for authoring
* — today exactly `flows` (`status: planned`, `authorWarn: true`).
* Translation groups the shipped liveness ledger warns an author for authoring.
* Today the set is empty: `flows`, the one group ever warned, is `live` now
* that both its halves are read, so nothing is gated.
*
* ## Why the walk surface is gated on this at all (#11624)
*
Expand All @@ -1083,20 +1085,20 @@ function walkObjectTabs(config: any, out: ExpectedEntry[]): void {
* `i18n/missing-*` family. Under `--i18n-strict` the demand side is an error,
* so a project could be *forced* to author keys it is then warned for.
*
* ⛔ The warn side is not the bug and must not be softened. Only part of the
* group is read: the console's screen-flow runner reads `screens`, but the
* flow's own `label` is read by nothing yet (#20318), so a translated flow
* label really is stored and never shown. The warn is group-level, so it still
* covers the whole group. The demand is the half that is premature.
* ⛔ The warn side was not the bug, and it was not softened. While the
* console's screen-flow runner read only `screens`, a translated flow `label`
* really was stored and never shown, and the group-level warn said so. The
* demand was the premature half, and it is the half this gate holds back.
* The runner now names the flow by `flows.<flow>.label` too, so the row is
* `live` and the warn and the gate are gone together.
*
* ## Shape
*
* Group-general, not `flows`-specific, and read from the ledger rather than a
* switch of our own: the day the row flips to `live` (dropping its
* `authorWarn`; for `flows` that waits on #20318), the bucket turns itself back
* on with no edit here — and any FUTURE group that acquires a warn is covered on
* the day it is marked, rather than re-opening this collision one group at a
* time.
* switch of our own: when the `flows` row flipped to `live` and dropped its
* `authorWarn`, the bucket turned itself back on with no edit here — and any
* FUTURE group that acquires a warn is covered on the day it is marked, rather
* than re-opening this collision one group at a time.
*
* The join is on the group (`path[0]`) and stops there deliberately: for
* file-authored bundles the warn side only ever fires at that depth. Its
Expand Down Expand Up @@ -1823,12 +1825,25 @@ function walkScreenFlows(config: any, out: ExpectedEntry[]): void {
if (!flowName) continue;
const scope: EntryScope = { flowName };

const nodes: any[] = collectFlowNodesDeep(flow.nodes);

// `flows.<flow>.label` — `lookupFlowLabel`'s key. `Flow.label` is required
// by the schema, so this is authored text in practice; `pushOptional`
// keeps a label-less flow from seeding an empty string anyway.
pushOptional(out, ['flows', flowName, 'label'], flow.label, 'flow', scope);

const nodes: any[] = collectFlowNodesDeep(flow.nodes);
//
// 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.
if (nodes.some((node) => node && typeof node === 'object' && node.type === SCREEN_NODE_TYPE)) {
pushOptional(out, ['flows', flowName, 'label'], flow.label, 'flow', scope);
}
for (const node of nodes) {
if (!node || typeof node !== 'object' || node.type !== SCREEN_NODE_TYPE) continue;
const nodeId = typeof node.id === 'string' && node.id.length > 0 ? node.id : undefined;
Expand Down
Loading
Loading