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
22 changes: 22 additions & 0 deletions .changeset/22507-flow-options-toasts-translation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@objectstack/spec": minor
"@objectstack/lint": minor
"@objectstack/cli": minor
---

feat(i18n): a screen field's option labels and a flow's terminal toasts have translation keys (`flows.<flow>.screens.<node_id>.fields.<field>.options.<value>`, `flows.<flow>.successMessage` / `.errorMessage`)

Clause-②: yes (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) A value-domain narrowing on one metadata shape, ScreenFieldConfigSchema's options array. No spec key, export, option, config field or stored shape is removed, renamed or re-shaped, so there is no tombstone and no rename for `objectstack migrate meta` to apply. What narrows is which option-value combinations one screen field accepts: two options whose values read the same as text are now refused at parse. There is no lossless rewrite for such a pair (nothing in the metadata says which of the two values the author meant, or what the other should become), and the refusal itself names the pair and the fix at every door that parses a flow. Measured reach is 0: no flow in this repository's examples carries such a pair. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id covers this rule and this diff adds none (not registered / already-registered); and no published interface or type is narrowed, the new members being additive (not runtime-interface-only / type-surface-only). -->

**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. Two options of one screen field whose values read the same as text — `1` and `"1"`, `true` and `"true"`, or a repeated value — are now refused when the flow is parsed (`FlowSchema`, `objectstack validate`, the metadata save door, `registerFlow`), at the second option's `value`. Neither the option's label translation (keyed by the value as text) nor the console's select can tell such a pair apart. To fix a refused flow, give each option of the field a value that stays distinct as text, or drop the duplicate. Measured reach in this repository: 0 — no flow in its examples carries such a pair.

- **What is new.** A fully translated screen flow no longer has to keep its select options and its completion or failure toast in the source language. Translate an option's label under `flows.<flow_name>.screens.<node_id>.fields.<field_name>.options.<value>`, and the flow's own toasts under `flows.<flow_name>.successMessage` and `flows.<flow_name>.errorMessage`.
- **How an option is addressed.** By its value read as text: `String(value)`, so the option `{ value: 1, label: 'Tier 1' }` is translated under `options.1` and `{ value: true, … }` under `options.true`. `flowScreenFieldOptionKey(value)` (`@objectstack/spec/automation`) spells the key. Not by position (reordering options would re-point every translation), and not by an object field's options (a screen field declares no such binding).
- **One new refusal**, the narrowing the BREAKING note above describes: option values of one screen field must stay distinct as text, because the label translation is keyed by them.
- **The toasts translate only where the flow authors one.** A bundle cannot add a toast to a flow that declares none; `objectstack validate` refuses such a key (`translation-target-unknown`, error).
- **Where it is read.** `translateFlow` (`@objectstack/spec/system`) overlays both, and `resolveFlowScreenFieldOptions(bundle, flowName, nodeId, field)` resolves a served screen field's options. The console's flow runner reads them in a following release; until then a translation is stored and the authored text is drawn.
- **`objectstack validate`** warns on an option key that names no declared option of the field, or names it by its label (`translation-option-key-unknown`).
- **`os i18n extract` and the coverage gate** now scaffold and demand each screen field's option labels, the failure toast of every flow that declares one, and the completion toast of a flow with a screen, in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` gets one missing-key issue per locale for each until it translates them.
- **Still not keyed: a screen's `description`.** It is a `{{ }}` template the server renders per run, so its translation has to be chosen before that render; a translated `description` is still refused by the schema with that reason.
2 changes: 1 addition & 1 deletion content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1624,7 +1624,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object>; … }>` | optional | Dashboard translations keyed by dashboard name |
| **datasets** | `Record<string, { label?: string; description?: string; dimensions?: Record<string, object>; measures?: Record<string, object> }>` | optional | Analytics dataset translations keyed by dataset name |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
| **flows** | `Record<string, { label?: string; screens?: Record<string, object>; refusals?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
| **flows** | `Record<string, { label?: string; successMessage?: string; errorMessage?: string; screens?: Record<string, object>; … }>` | optional | Screen-flow translations keyed by flow name |
| **metadataForms** | `Record<string, { label?: string; description?: string; sections?: Record<string, object>; fields?: Record<string, object> }>` | optional | Translations for metadata-type configuration forms keyed by metadata type |
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |
| **settings** | `Record<string, { title?: string; description?: string; groups?: Record<string, object>; keys?: Record<string, object>; … }>` | optional | Settings manifest translations keyed by namespace |
Expand Down
6 changes: 3 additions & 3 deletions content/docs/references/automation/builtin-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
| **label** | `string` | optional | Display label |
| **type** | `string` | optional | Input type |
| **required** | `boolean` | optional | Whether a value is required to submit |
| **options** | `{ value: any; label: string }[]` | optional | Choices for a select-style field |
| **options** | `{ value: any; label: string }[]` | optional | Choices for a select-style field; option values must stay distinct when read as text |
| **defaultValue** | `any` | optional | Prefilled value (interpolates `{token}` templates) |
| **placeholder** | `string` | optional | Input placeholder text |
| **visibleWhen** | `string` | optional | CEL predicate controlling visibility, evaluated client-side |
Expand All @@ -263,7 +263,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
| **label** | `string` | optional | Display label |
| **type** | `string` | optional | Input type |
| **required** | `boolean` | optional | Whether a value is required to submit |
| **options** | `{ value: any; label: string }[]` | optional | Choices for a select-style field |
| **options** | `{ value: any; label: string }[]` | optional | Choices for a select-style field; option values must stay distinct when read as text |
| **defaultValue** | `any` | optional | Prefilled value (interpolates `{token}` templates) |
| **placeholder** | `string` | optional | Input placeholder text |
| **visibleWhen** | `string` | optional | CEL predicate controlling visibility, evaluated client-side |
Expand All @@ -276,7 +276,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **value** | `any` | ✅ | Stored value |
| **value** | `any` | ✅ | Stored value; it also addresses the option as text (its label translation key), so it must stay distinct from its siblings as text |
| **label** | `string` | ✅ | Display label |


Expand Down
12 changes: 9 additions & 3 deletions content/docs/references/system/translation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ Platform translation data — the per-app groups plus the platform-only `setting
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object>; … }>` | optional | Dashboard translations keyed by dashboard name |
| **datasets** | `Record<string, { label?: string; description?: string; dimensions?: Record<string, object>; measures?: Record<string, object> }>` | optional | Analytics dataset translations keyed by dataset name |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
| **flows** | `Record<string, { label?: string; screens?: Record<string, object>; refusals?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
| **flows** | `Record<string, { label?: string; successMessage?: string; errorMessage?: string; screens?: Record<string, object>; … }>` | optional | Screen-flow translations keyed by flow name |
| **metadataForms** | `Record<string, { label?: string; description?: string; sections?: Record<string, object>; fields?: Record<string, object> }>` | optional | Translations for metadata-type configuration forms keyed by metadata type |
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |
| **settings** | `Record<string, { title?: string; description?: string; groups?: Record<string, object>; keys?: Record<string, object>; … }>` | optional | Settings manifest translations keyed by namespace |
Expand Down Expand Up @@ -258,6 +258,8 @@ Translation data for a single object
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **label** | `string` | optional | Translated flow label |
| **successMessage** | `string` | optional | Translated completion toast — overlays the flow's own `successMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated |
| **errorMessage** | `string` | optional | Translated failure toast — overlays the flow's own `errorMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated |
| **screens** | `Record<string, { title?: string; fields?: Record<string, object> }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) |
| **refusals** | `Record<string, { message?: string }>` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` |

Expand Down Expand Up @@ -376,7 +378,7 @@ Per-app translation data for objects, apps, and UI messages
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object>; … }>` | optional | Dashboard translations keyed by dashboard name |
| **datasets** | `Record<string, { label?: string; description?: string; dimensions?: Record<string, object>; measures?: Record<string, object> }>` | optional | Analytics dataset translations keyed by dataset name |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
| **flows** | `Record<string, { label?: string; screens?: Record<string, object>; refusals?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
| **flows** | `Record<string, { label?: string; successMessage?: string; errorMessage?: string; screens?: Record<string, object>; … }>` | optional | Screen-flow translations keyed by flow name |
| **metadataForms** | `Record<string, { label?: string; description?: string; sections?: Record<string, object>; fields?: Record<string, object> }>` | optional | Translations for metadata-type configuration forms keyed by metadata type |
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |

Expand Down Expand Up @@ -457,6 +459,8 @@ Translation data for a single object
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **label** | `string` | optional | Translated flow label |
| **successMessage** | `string` | optional | Translated completion toast — overlays the flow's own `successMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated |
| **errorMessage** | `string` | optional | Translated failure toast — overlays the flow's own `errorMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated |
| **screens** | `Record<string, { title?: string; fields?: Record<string, object> }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) |
| **refusals** | `Record<string, { message?: string }>` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` |

Expand Down Expand Up @@ -526,7 +530,7 @@ One locale of translations — the `translation` metadata type
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, object>; widgets?: Record<string, object>; … }>` | optional | Dashboard translations keyed by dashboard name |
| **datasets** | `Record<string, { label?: string; description?: string; dimensions?: Record<string, object>; measures?: Record<string, object> }>` | optional | Analytics dataset translations keyed by dataset name |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
| **flows** | `Record<string, { label?: string; screens?: Record<string, object>; refusals?: Record<string, object> }>` | optional | Screen-flow translations keyed by flow name |
| **flows** | `Record<string, { label?: string; successMessage?: string; errorMessage?: string; screens?: Record<string, object>; … }>` | optional | Screen-flow translations keyed by flow name |
| **metadataForms** | `Record<string, { label?: string; description?: string; sections?: Record<string, object>; fields?: Record<string, object> }>` | optional | Translations for metadata-type configuration forms keyed by metadata type |
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |
| **locale** | `string` | ✅ | BCP-47 locale this item translates (e.g. "zh-CN") |
Expand Down Expand Up @@ -617,6 +621,8 @@ Translation data for a single object
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **label** | `string` | optional | Translated flow label |
| **successMessage** | `string` | optional | Translated completion toast — overlays the flow's own `successMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated |
| **errorMessage** | `string` | optional | Translated failure toast — overlays the flow's own `errorMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated |
| **screens** | `Record<string, { title?: string; fields?: Record<string, object> }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) |
| **refusals** | `Record<string, { message?: string }>` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` |

Expand Down
2 changes: 2 additions & 0 deletions examples/app-crm/src/translations/crm.translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@ export const CrmTranslationBundle = defineTranslationBundle({
flows: {
crm_convert_lead_wizard: {
label: '将线索转化为客户和商机',
successMessage: '🎉 线索已转化 — 已创建客户和商机。',
errorMessage: '线索转化未完成 — 请检查线索后重试。',
screens: {
screen_already_converted: { title: '已转化' },
screen_account: { title: '第 1 步,共 2 步 · 客户' },
Expand Down
10 changes: 8 additions & 2 deletions examples/app-todo/src/translations/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,15 @@ export const jaJP: TranslationData = {
title: 'タスクの詳細',
fields: {
subject: { label: 'タスクの件名' },
priority: { label: '優先度' },
priority: {
label: '優先度',
options: { low: '低', normal: '通常', high: '高', urgent: '緊急' },
},
dueDate: { label: '期日' },
category: { label: 'カテゴリ' },
category: {
label: 'カテゴリ',
options: { personal: '個人', work: '仕事', shopping: '買い物', health: '健康', finance: '財務', other: 'その他' },
},
},
},
success_screen: { title: 'タスクを作成しました' },
Expand Down
10 changes: 8 additions & 2 deletions examples/app-todo/src/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,15 @@ export const zhCN: TranslationData = {
title: '任务详情',
fields: {
subject: { label: '任务主题' },
priority: { label: '优先级' },
priority: {
label: '优先级',
options: { low: '低', normal: '普通', high: '高', urgent: '紧急' },
},
dueDate: { label: '截止日期' },
category: { label: '分类' },
category: {
label: '分类',
options: { personal: '个人', work: '工作', shopping: '购物', health: '健康', finance: '财务', other: '其他' },
},
},
},
success_screen: { title: '任务已创建' },
Expand Down
8 changes: 5 additions & 3 deletions packages/cli/src/utils/i18n-coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,8 +356,10 @@ const COVERAGE_SOURCE: Record<ExpectedEntry['source'], CoverageIssue['source']>
picklist: 'picklist',
page: 'page',
// Screen-flow copy (`flows.<f>.label`, `flows.<f>.screens.<n>.title`, and
// the per-field `label` / `placeholder`) and a refused `end` node's message
// (`flows.<f>.refusals.<n>.message`, #22450) — the author's own text, so
// 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
// 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
Expand Down Expand Up @@ -426,7 +428,7 @@ const SOURCE_SURFACE: Record<CoverageIssue['source'], string> = {
dataset: 'datasets',
picklist: 'picklists',
page: 'pages',
flow: 'flow screens and refusals',
flow: 'flow screens, toasts and refusals',
metadataForm: 'metadata forms',
};

Expand Down
Loading
Loading