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
13 changes: 13 additions & 0 deletions .changeset/17306-flow-screen-field-help-text-translation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/spec': minor
---

A flow screen field's help text is translatable: the `flows` translation face carries `inlineHelpText` beside `label` and `placeholder` (#17306).

Clause-②: yes (widening)

- **`TranslationDataSchema`.** `flows.<flow>.screens.<node_id>.fields.<field>` accepts `inlineHelpText`, the key the screen field itself declares (`ScreenFieldConfig.inlineHelpText`, the object field's spelling). The console's screen dialog draws that text under the control, so a translated help line now renders in the active locale.
- **`FLOW_SCREEN_FIELD_COPY_KEYS`** (`@objectstack/spec/system`) is `['label', 'placeholder', 'inlineHelpText']`. Its readers follow it without an edit: `translateFlow` overlays the key, `os i18n extract` scaffolds it, and objectui's `FlowRunner` overlays it on the field it draws. `FlowScreenFieldLike` gains the optional `inlineHelpText` member.
- **Refusals.** `help`, `helpText`, `hint`, `tooltip` and `description` on a screen field translation are still refused, and the message now names the rename to `inlineHelpText`. They used to be told that the face had no help key. `options` is still refused with its guidance.

Nothing that parsed before is refused now. A bundle that never wrote a help line is unchanged.
8 changes: 5 additions & 3 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -489,9 +489,11 @@ only, which is all a bound can do.
hidden `visibleWhen` field does not fire — the client is the authority on what
was on screen.

Not translatable yet: the flows translation bundle carries `label` and
`placeholder` per field, so `inlineHelpText` renders in the authored language
until that face grows a key for it.
Translatable: a screen field's `inlineHelpText` is translated under
`flows.<flow>.screens.<node_id>.fields.<field>.inlineHelpText`, beside `label`
and `placeholder`, and the console's flow runner overlays it in the active
locale — see the flows row in
[Translations](/docs/ui/translations#what-you-can-translate).

**Screen (object form):**

Expand Down
19 changes: 10 additions & 9 deletions content/docs/ui/translations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export default defineStack({
| Dataset dimension and measure labels | `datasets.<name>.dimensions.<dimension>.label` / `datasets.<name>.measures.<measure>.label` |
| Page labels and `page:header` copy | `pages.<name>.label` / `description` / `title` / `subtitle` — on a `kind: 'slotted'` page the header under `slots.header` is the page's header |
| Page component copy, by component id | `pages.<name>.components.<id>.title` / `description` / `label` / `placeholder` / `emptyText` — reached under `regions[].components[]` and `slots.<slot>`, through `properties.children` and a `page:tabs` / `page:accordion` panel's `items[].children` |
| Screen-flow wizards (flow label, screen headings, screen field copy) | `flows.<flow>.label` / `flows.<flow>.screens.<node_id>.title` / `.fields.<field>.label` / `.placeholder` — see the boundary note below |
| Screen-flow wizards (flow label, screen headings, screen field copy) | `flows.<flow>.label` / `flows.<flow>.screens.<node_id>.title` / `.fields.<field>.label` / `.placeholder` / `.inlineHelpText` — a screen field's help text is `inlineHelpText`, the key the field itself declares, not `help`; see the boundary note below |
| Global actions, messages | `globalActions`, `messages` |
| Settings UI shell copy (the source badge on a settings row) | `settingsCommon.sourceLabels.<layer>` — the per-namespace settings copy under `settings` is **platform-only**: an app bundle carrying it is refused by name, and the platform's own strings are translated in `@objectstack/service-settings`'s bundle |
| A label written as an inline locale map (`label: { en: 'Members', 'zh-CN': '成员' }`) | Nowhere — it is written on the metadata and resolved at render time; see **Current boundaries** below |
Expand Down Expand Up @@ -382,15 +382,16 @@ 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 declared, not yet applied.** A screen flow's copy has
- **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 — but no shipped
screen-flow runner reads the group yet, so a wizard still renders the strings
authored on the flow. The liveness ledger carries it as `planned` and the
compile lint warns when you author it. Two related limits are deliberate: a
screen field has no help text to translate (it declares none), and the
runner's own chrome — the Cancel and Submit buttons — belongs to the
console's message catalog rather than your app's bundle.
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
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/test/i18n-flow-liveness-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,10 @@ describe('the liveness gate on the i18n coverage walk', () => {

expect(keys).toEqual([
'flows.lead_conversion.label',
'flows.lead_conversion.screens.conversion_details.fields.create_opportunity.inlineHelpText',
'flows.lead_conversion.screens.conversion_details.fields.create_opportunity.label',
'flows.lead_conversion.screens.conversion_details.fields.create_opportunity.placeholder',
'flows.lead_conversion.screens.conversion_details.fields.opportunity_name.inlineHelpText',
'flows.lead_conversion.screens.conversion_details.fields.opportunity_name.label',
'flows.lead_conversion.screens.conversion_details.fields.opportunity_name.placeholder',
'flows.lead_conversion.screens.conversion_details.title',
Expand Down
18 changes: 16 additions & 2 deletions packages/cli/test/i18n-flow-screen-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,14 @@ const leadConversion = {
description: 'Choose what this lead becomes.',
fields: [
{ name: 'create_opportunity', label: 'Create Opportunity?', type: 'boolean' },
{ name: 'opportunity_name', label: 'Opportunity Name', placeholder: 'Acme - Q3 renewal' },
{
name: 'opportunity_name',
label: 'Opportunity Name',
placeholder: 'Acme - Q3 renewal',
// Every per-field copy key is authored on this one field, so the
// key-face pin below compares the whole spec list, not a subset.
inlineHelpText: 'Shown on the quote',
},
],
},
},
Expand Down Expand Up @@ -137,6 +144,7 @@ describe('the screen-flow gap a green i18n gate could not see (#11485)', () => {
expect(keys).toContain('flows.lead_conversion.screens.conversion_details.fields.create_opportunity.label');
expect(keys).toContain('flows.lead_conversion.screens.conversion_details.fields.opportunity_name.label');
expect(keys).toContain('flows.lead_conversion.screens.conversion_details.fields.opportunity_name.placeholder');
expect(keys).toContain('flows.lead_conversion.screens.conversion_details.fields.opportunity_name.inlineHelpText');
// The object surface IS translated, so nothing else is reported: the whole
// report is the wizard. Before this bucket the same tree reported zero.
expect(zh.length).toBeGreaterThan(0);
Expand Down Expand Up @@ -166,7 +174,7 @@ describe('the screen-flow gap a green i18n gate could not see (#11485)', () => {
title: '转化详情',
fields: {
create_opportunity: { label: '创建商机?' },
opportunity_name: { label: '商机名称', placeholder: 'Acme - 第三季度续约' },
opportunity_name: { label: '商机名称', placeholder: 'Acme - 第三季度续约', inlineHelpText: '显示在报价单上' },
},
},
summary: { title: '完成' },
Expand All @@ -192,8 +200,10 @@ describe('what the walker harvests from a screen flow', () => {
it('keys screens by `FlowNode.id` and fields by `ScreenFieldConfig.name`', () => {
expect(flowKeys({ flows: [leadConversion] }).sort()).toEqual([
'flows.lead_conversion.label',
'flows.lead_conversion.screens.conversion_details.fields.create_opportunity.inlineHelpText',
'flows.lead_conversion.screens.conversion_details.fields.create_opportunity.label',
'flows.lead_conversion.screens.conversion_details.fields.create_opportunity.placeholder',
'flows.lead_conversion.screens.conversion_details.fields.opportunity_name.inlineHelpText',
'flows.lead_conversion.screens.conversion_details.fields.opportunity_name.label',
'flows.lead_conversion.screens.conversion_details.fields.opportunity_name.placeholder',
'flows.lead_conversion.screens.conversion_details.title',
Expand Down Expand Up @@ -276,6 +286,7 @@ describe('`os i18n extract` scaffolds the flows skeleton', () => {
expect(en.flows.lead_conversion.screens.conversion_details.fields.opportunity_name).toEqual({
label: 'Opportunity Name',
placeholder: 'Acme - Q3 renewal',
inlineHelpText: 'Shown on the quote',
});
// The translator's empty slots — the vocabulary an author had no way to
// discover before this pass existed.
Expand Down Expand Up @@ -424,13 +435,16 @@ describe('a screen inside an ADR-0031 region (#17511)', () => {
// The exact face, so a key that should NOT exist fails here too. Eight of
// these ten were absent before the descent landed; `flows.onboarding.label`
// and `screens.welcome.title` are the two the flat walk already reached.
// The two `inlineHelpText` rows joined with the per-field face (#17306).
expect(flowKeys({ flows: [nestedOnboarding] }).sort()).toEqual([
'flows.onboarding.label',
'flows.onboarding.screens.accept_terms.title',
'flows.onboarding.screens.card_details.title',
'flows.onboarding.screens.payment_failed.title',
'flows.onboarding.screens.pick_region.fields.notes.inlineHelpText',
'flows.onboarding.screens.pick_region.fields.notes.label',
'flows.onboarding.screens.pick_region.fields.notes.placeholder',
'flows.onboarding.screens.pick_region.fields.region_code.inlineHelpText',
'flows.onboarding.screens.pick_region.fields.region_code.label',
'flows.onboarding.screens.pick_region.fields.region_code.placeholder',
'flows.onboarding.screens.pick_region.title',
Expand Down
Loading
Loading