diff --git a/.changeset/flow-screen-copy-resolver-family.md b/.changeset/flow-screen-copy-resolver-family.md new file mode 100644 index 0000000000..4bb8d1ea38 --- /dev/null +++ b/.changeset/flow-screen-copy-resolver-family.md @@ -0,0 +1,14 @@ +--- +"@objectstack/spec": minor +--- + +**i18n:** the screen-flow copy vocabulary gains its resolver family — `translateFlow`, `resolveFlowScreenTitle`, and the `FLOW_SCREEN_COPY_KEYS` / `FLOW_SCREEN_FIELD_COPY_KEYS` shared key lists (#7646 recommendation B, the spec half of #11287). + +#7763 declared `TranslationData.flows` (`flows..label`, `.screens..title`, `.screens..fields..{label,placeholder}`) and deliberately left the resolver half unwritten because another change was in flight on `i18n-resolver.ts`. This lands that half in `packages/spec/src/system/i18n-resolver.ts`, mirroring the page family's conventions: + +- `translateFlow(flow, bundle, opts)` — the metadata-document overlay: translates the flow's own `label` and, for every `type: 'screen'` node with an id, the screen heading (`config.title` — written even when the author relied on the node-label fallback, since the executor builds the wire title as `config.title ?? node.label`) and per-field `label` / `placeholder`, key by key across the locale chain with the authored source strings as fallback. Input not mutated; off-spec bundle keys the schema refuses (`description`, `help`) are ignored, never overlaid. +- `resolveFlowScreenTitle(bundle, flowName, screen, opts)` — the piecemeal half for a caller already holding a `ScreenSpec` (`nodeId` addressing, `title` literal fallback). +- `FLOW_SCREEN_COPY_KEYS` (`['title']`) and `FLOW_SCREEN_FIELD_COPY_KEYS` (`['label', 'placeholder']`) — the one list the resolver overlay and the CLI's skeleton extractor (downstream card) both import, pinned against `TranslationDataSchema` so neither can drift. +- Supporting shapes: `FlowLike`, `FlowNodeLike`, `FlowScreenLike`, `FlowScreenFieldLike`, `FlowScreenCopyKey`, `FlowScreenFieldCopyKey`. + +Additive surface widening only — no existing shape changes meaning, and `translateFlow` is deliberately **not** registered in `translateMetadataDocument`'s dispatch table: that registration reaches the REST metadata boundary by itself (`TRANSLATABLE_METADATA_TYPES` drives `@objectstack/rest`), which would stand up a shipped reader of the `flows` group while its liveness ledger rows are `planned`. The runner application (server or client side), the CLI coverage bucket, and the ledger flip to `live` all ride the downstream cards of #11287. diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index c92b3376b6..d7782844eb 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -227,6 +227,8 @@ "ExternalServiceDestinationConfig (type)", "ExternalServiceDestinationConfigSchema (const)", "FILE_REFERENCES_MIGRATION_ID (const)", + "FLOW_SCREEN_COPY_KEYS (const)", + "FLOW_SCREEN_FIELD_COPY_KEYS (const)", "FacetConfig (type)", "FacetConfigParsed (type)", "FacetConfigSchema (const)", @@ -248,6 +250,12 @@ "FileDestinationConfigSchema (const)", "FileMetadata (type)", "FileMetadataSchema (const)", + "FlowLike (interface)", + "FlowNodeLike (interface)", + "FlowScreenCopyKey (type)", + "FlowScreenFieldCopyKey (type)", + "FlowScreenFieldLike (interface)", + "FlowScreenLike (interface)", "GCounter (type)", "GCounterSchema (const)", "HistogramBucketConfig (type)", @@ -784,6 +792,7 @@ "resolveBundleLocale (function)", "resolveDocAudiences (function)", "resolveDocLocale (function)", + "resolveFlowScreenTitle (function)", "resolveMetadataFormLabels (function)", "resolveMetadataTypeDescription (function)", "resolveMetadataTypeLabel (function)", @@ -810,6 +819,7 @@ "translateAction (function)", "translateApp (function)", "translateDashboard (function)", + "translateFlow (function)", "translateMetadataDocument (function)", "translateObject (function)", "translatePage (function)", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 874d6fd158..5dde883a9f 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -227,6 +227,8 @@ "ExternalServiceDestinationConfig": "src/system/logging.zod.ts#ExternalServiceDestinationConfig (type)", "ExternalServiceDestinationConfigSchema": "src/system/logging.zod.ts#ExternalServiceDestinationConfigSchema (const)", "FILE_REFERENCES_MIGRATION_ID": "src/system/migration.zod.ts#FILE_REFERENCES_MIGRATION_ID (const)", + "FLOW_SCREEN_COPY_KEYS": "src/system/i18n-resolver.ts#FLOW_SCREEN_COPY_KEYS (const)", + "FLOW_SCREEN_FIELD_COPY_KEYS": "src/system/i18n-resolver.ts#FLOW_SCREEN_FIELD_COPY_KEYS (const)", "FacetConfig": "src/system/search-engine.zod.ts#FacetConfig (type)", "FacetConfigParsed": "src/system/search-engine.zod.ts#FacetConfigParsed (type)", "FacetConfigSchema": "src/system/search-engine.zod.ts#FacetConfigSchema (const)", @@ -248,6 +250,12 @@ "FileDestinationConfigSchema": "src/system/logging.zod.ts#FileDestinationConfigSchema (const)", "FileMetadata": "src/system/object-storage.zod.ts#FileMetadata (type)", "FileMetadataSchema": "src/system/object-storage.zod.ts#FileMetadataSchema (const)", + "FlowLike": "src/system/i18n-resolver.ts#FlowLike (interface)", + "FlowNodeLike": "src/system/i18n-resolver.ts#FlowNodeLike (interface)", + "FlowScreenCopyKey": "src/system/i18n-resolver.ts#FlowScreenCopyKey (type)", + "FlowScreenFieldCopyKey": "src/system/i18n-resolver.ts#FlowScreenFieldCopyKey (type)", + "FlowScreenFieldLike": "src/system/i18n-resolver.ts#FlowScreenFieldLike (interface)", + "FlowScreenLike": "src/system/i18n-resolver.ts#FlowScreenLike (interface)", "GCounter": "src/system/collaboration.zod.ts#GCounter (type)", "GCounterSchema": "src/system/collaboration.zod.ts#GCounterSchema (const)", "HistogramBucketConfig": "src/system/metrics.zod.ts#HistogramBucketConfig (type)", @@ -784,6 +792,7 @@ "resolveBundleLocale": "src/system/i18n-resolver.ts#resolveBundleLocale (function)", "resolveDocAudiences": "src/system/book.zod.ts#resolveDocAudiences (function)", "resolveDocLocale": "src/system/doc.zod.ts#resolveDocLocale (function)", + "resolveFlowScreenTitle": "src/system/i18n-resolver.ts#resolveFlowScreenTitle (function)", "resolveMetadataFormLabels": "src/system/i18n-resolver.ts#resolveMetadataFormLabels (function)", "resolveMetadataTypeDescription": "src/system/i18n-resolver.ts#resolveMetadataTypeDescription (function)", "resolveMetadataTypeLabel": "src/system/i18n-resolver.ts#resolveMetadataTypeLabel (function)", @@ -810,6 +819,7 @@ "translateAction": "src/system/i18n-resolver.ts#translateAction (function)", "translateApp": "src/system/i18n-resolver.ts#translateApp (function)", "translateDashboard": "src/system/i18n-resolver.ts#translateDashboard (function)", + "translateFlow": "src/system/i18n-resolver.ts#translateFlow (function)", "translateMetadataDocument": "src/system/i18n-resolver.ts#translateMetadataDocument (function)", "translateObject": "src/system/i18n-resolver.ts#translateObject (function)", "translatePage": "src/system/i18n-resolver.ts#translatePage (function)", diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index e9b6d97e60..f6e8d971fb 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -2012,3 +2012,213 @@ describe('translateObject — catalog vs explicit override (#8284)', () => { expect(out.pluralLabel).toBe('客户'); }); }); + +// ════════════════════════════════════════════════════════════════════════════ +// Screen-flow copy resolvers (#7646 / #11287) +// ════════════════════════════════════════════════════════════════════════════ + +import { + translateFlow, + resolveFlowScreenTitle, + translateMetadataDocument as translateMetadataDocumentForFlows, + TRANSLATABLE_METADATA_TYPES as TRANSLATABLE_TYPES_FOR_FLOWS, +} from './i18n-resolver'; +import { type TranslationBundle as FlowTestBundle } from './translation.zod'; + +describe('translateFlow (#11287)', () => { + // Mirrors #11287's measurement: HotCRM's real `lead_conversion` flow with + // the real zh-CN bundle carrying the `flows` section. The probe + // `i18n.t('flows.lead_conversion.screens.screen_1.title','zh-CN') = 转化详情` + // resolved by key with the existing tree — this family is the reader that + // was missing. + const bundle: FlowTestBundle = { + 'zh-CN': { + flows: { + lead_conversion: { + label: '转化线索', + screens: { + screen_1: { + title: '转化详情', + fields: { + createOpportunity: { label: '创建商机?' }, + opportunityName: { label: '商机名称', placeholder: '输入商机名称' }, + opportunityAmount: { label: '商机金额' }, + }, + }, + }, + }, + }, + }, + en: { + flows: { + lead_conversion: { + screens: { screen_1: { fields: { createOpportunity: { label: 'Create Opportunity? (en)' } } } }, + }, + }, + }, + }; + + const leadConversion = () => ({ + name: 'lead_conversion', + label: 'Convert Lead', + type: 'screen', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'screen_1', + type: 'screen', + label: 'Conversion Details', + config: { + title: 'Conversion Details', + description: 'Review the details below.', + fields: [ + { name: 'createOpportunity', label: 'Create Opportunity?', type: 'boolean' }, + { name: 'opportunityName', label: 'Opportunity Name', placeholder: 'Enter a name' }, + { name: 'opportunityAmount', label: 'Opportunity Amount' }, + ], + }, + }, + { id: 'finish', type: 'assignment', label: 'Assign', config: { assignments: [] } }, + ], + edges: [{ id: 'e1', source: 'start', target: 'screen_1' }], + }); + + const screenOf = (doc: any, id = 'screen_1') => doc.nodes.find((n: any) => n.id === id); + + it('translates the flow label, screen title, field labels and placeholder — the measured surfaces', () => { + const out = translateFlow(leadConversion(), bundle, { locale: 'zh-CN' }); + expect(out.label).toBe('转化线索'); + const screen = screenOf(out); + expect(screen.config.title).toBe('转化详情'); + const byName = (n: string) => screen.config.fields.find((f: any) => f.name === n); + expect(byName('createOpportunity').label).toBe('创建商机?'); + expect(byName('opportunityName').label).toBe('商机名称'); + expect(byName('opportunityName').placeholder).toBe('输入商机名称'); + expect(byName('opportunityAmount').label).toBe('商机金额'); + }); + + it('falls back to the authored source strings for keys the bundle omits', () => { + const out = translateFlow(leadConversion(), bundle, { locale: 'zh-CN' }); + const screen = screenOf(out); + // The bundle carries no placeholder for these two — authored copy stands. + expect(screen.config.fields.find((f: any) => f.name === 'createOpportunity').placeholder) + .toBeUndefined(); + expect(screen.config.fields.find((f: any) => f.name === 'opportunityAmount').placeholder) + .toBeUndefined(); + // Non-copy config and field keys ride through untouched. + expect(screen.config.fields.find((f: any) => f.name === 'createOpportunity').type).toBe('boolean'); + expect(screen.config.description).toBe('Review the details below.'); + }); + + it('negative control — a flow the bundle does not carry comes back unchanged (same reference)', () => { + const doc = { ...leadConversion(), name: 'other_flow' }; + expect(translateFlow(doc, bundle, { locale: 'zh-CN' })).toBe(doc); + }); + + it('resolves KEY BY KEY across the locale chain — a partial zh entry still falls back to en', () => { + const partialZh: FlowTestBundle = { + 'zh-CN': { + flows: { lead_conversion: { screens: { screen_1: { title: '转化详情' } } } }, + }, + en: bundle.en, + }; + const out = translateFlow(leadConversion(), partialZh, { locale: 'zh-CN' }); + const screen = screenOf(out); + expect(screen.config.title).toBe('转化详情'); + expect(screen.config.fields.find((f: any) => f.name === 'createOpportunity').label) + .toBe('Create Opportunity? (en)'); + }); + + it('writes the translated title even when the author declared no `config.title`', () => { + // The executor builds the wire title as `config.title ?? node.label`, so + // writing `config.title` covers whichever of the two the author relied on + // — the one-key-covers-both rule the schema's `flows` note records. + const doc = leadConversion(); + delete (screenOf(doc).config as any).title; + const out = translateFlow(doc, bundle, { locale: 'zh-CN' }); + expect(screenOf(out).config.title).toBe('转化详情'); + // The designer-canvas node label is NOT on the vocabulary — untouched. + expect(screenOf(out).label).toBe('Conversion Details'); + }); + + it('ignores off-spec bundle keys the schema refuses — never overlays them (`description`, `help`)', () => { + // The resolver is deliberately schema-independent (stored rows reach it + // via the raw sync path), so the negative is pinned on its own, the same + // way `translatePage` pins the retired `submitLabel`. + const offSpec = { + 'zh-CN': { + flows: { + lead_conversion: { + screens: { + screen_1: { + description: '不应出现', + fields: { opportunityName: { help: '不应出现' } }, + }, + }, + }, + }, + }, + } as unknown as FlowTestBundle; + const out = translateFlow(leadConversion(), offSpec, { locale: 'zh-CN' }); + const screen = screenOf(out); + expect(screen.config.description).toBe('Review the details below.'); + expect(screen.config.fields.find((f: any) => f.name === 'opportunityName').help).toBeUndefined(); + }); + + it('touches only screen nodes with an id, and does not mutate the input document', () => { + const doc = leadConversion(); + const frozen = JSON.parse(JSON.stringify(doc)); + const out = translateFlow(doc, bundle, { locale: 'zh-CN' }); + expect(doc).toEqual(frozen); // input untouched + expect(screenOf(out, 'start')).toBe(doc.nodes[0]); // non-screen node: same reference + expect(screenOf(out, 'finish')).toBe(doc.nodes[2]); + const anonymous = { + ...leadConversion(), + nodes: [{ type: 'screen', label: 'No Id', config: { title: 'No Id' } }], + }; + const outAnon = translateFlow(anonymous, bundle, { locale: 'zh-CN' }); + expect(outAnon.nodes[0]).toBe(anonymous.nodes[0]); // id-less screen: untouched + }); + + it('is deliberately NOT in the metadata-document dispatch — the runner card owns that wiring', () => { + // Registering `flow` in METADATA_DOCUMENT_TRANSLATORS reaches the REST + // metadata boundary by itself (TRANSLATABLE_METADATA_TYPES drives + // @objectstack/rest, #3786), which would stand up a shipped reader of the + // `flows` group while its liveness rows are `planned`. Pin the absence so + // adding it is a deliberate act on the runner card, not a drive-by. + expect(TRANSLATABLE_TYPES_FOR_FLOWS.has('flow')).toBe(false); + const doc = leadConversion(); + expect(translateMetadataDocumentForFlows('flow', doc, bundle, { locale: 'zh-CN' })).toBe(doc); + }); +}); + +describe('resolveFlowScreenTitle (#11287)', () => { + const bundle: FlowTestBundle = { + 'zh-CN': { + flows: { lead_conversion: { screens: { screen_1: { title: '转化详情' } } } }, + }, + }; + + it('resolves against flows..screens..title from a ScreenSpec-shaped input', () => { + // The shape a paused AutomationResult carries client-side. + const screen = { nodeId: 'screen_1', title: 'Conversion Details', fields: [] }; + expect(resolveFlowScreenTitle(bundle, 'lead_conversion', screen, { locale: 'zh-CN' })) + .toBe('转化详情'); + }); + + it('falls back to the literal screen title, then undefined — the family order', () => { + expect(resolveFlowScreenTitle(bundle, 'lead_conversion', { nodeId: 'screen_2', title: 'Step 2' }, { locale: 'zh-CN' })) + .toBe('Step 2'); + expect(resolveFlowScreenTitle(bundle, 'other_flow', { nodeId: 'screen_1', title: 'Details' }, { locale: 'zh-CN' })) + .toBe('Details'); + expect(resolveFlowScreenTitle(undefined, 'lead_conversion', { nodeId: 'screen_1' }, { locale: 'zh-CN' })) + .toBeUndefined(); + expect(resolveFlowScreenTitle(bundle, 'other_flow', { nodeId: 'screen_2' }, { locale: 'zh-CN' })) + .toBeUndefined(); + }); + + it('applies the BCP-47 ladder the rest of the surface uses', () => { + expect(resolveFlowScreenTitle(bundle, 'lead_conversion', { nodeId: 'screen_1' }, { locale: 'zh' })) + .toBe('转化详情'); + }); +}); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index dea0881eb4..59de0b11c7 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -2031,3 +2031,309 @@ export function resolveMetadataFormLabels>( } return next as T; } + +// ──────────────────────────────────────────────────────────────────────────── +// Screen-flow metadata resolvers (#7646 / #11287) — flows..… +// ──────────────────────────────────────────────────────────────────────────── + +/** + * Minimal screen-node field shape consumed by {@link translateFlow} — + * `ScreenFieldConfigSchema` (`automation/builtin-node-config.zod.ts`) narrowed + * to what the overlay reads and writes. The served `ScreenFieldSpec` + * (`contracts/automation-service.ts`) satisfies it structurally too: the + * executor forwards `name` / `label` / `placeholder` verbatim, so the same + * overlay works whichever side of the wire the runner half lands on. + */ +export interface FlowScreenFieldLike { + /** + * Field name (`ScreenFieldConfig.name`) — the `fields` translation key, + * forwarded to the client verbatim as `ScreenFieldSpec.name`. A field + * without a name is skipped, matching the executor (which drops it). + */ + name?: string; + label?: string; + placeholder?: string; + [key: string]: unknown; +} + +/** Minimal flow-node shape consumed by {@link translateFlow}. */ +export interface FlowNodeLike { + /** `FlowNode.id` — the `screens` translation key (the client's `ScreenSpec.nodeId`). */ + id?: string; + /** Node action type; only `'screen'` nodes carry the copy this family overlays. */ + type?: string; + /** Designer-canvas node label — NOT overlaid; see {@link translateFlow}. */ + label?: string; + /** Node configuration (`ScreenConfigSchema` for screen nodes). */ + config?: Record; + [key: string]: any; +} + +/** Minimal flow metadata shape consumed by {@link translateFlow}. */ +export interface FlowLike { + /** `Flow.name` — the machine name the `flows` group is keyed by. */ + name: string; + label?: string; + nodes?: FlowNodeLike[]; + [key: string]: any; +} + +/** + * Minimal screen shape consumed by {@link resolveFlowScreenTitle} — satisfied + * by the served `ScreenSpec` (`contracts/automation-service.ts`) a paused run + * hands the client, and equally by a `{ nodeId, title }` pair assembled from a + * screen node's `id` + `config.title` on the server side. + */ +export interface FlowScreenLike { + /** The screen node's id (`FlowNode.id`) — the `screens` translation key. */ + nodeId: string; + /** The authored heading the runner would draw untranslated (`ScreenSpec.title`). */ + title?: string; + [key: string]: unknown; +} + +/** The flow-node type whose config copy {@link translateFlow} localizes. */ +const SCREEN_NODE_TYPE = 'screen'; + +/** + * The copy keys `flows..screens.` carries (#7646 / #11287) — + * the per-SCREEN face; the per-field face is + * {@link FLOW_SCREEN_FIELD_COPY_KEYS}. Exported for the same reason + * {@link PAGE_COMPONENT_COPY_KEYS} is: the CLI's `i18n-extract` skeleton + * writer (the flow/screen coverage bucket is a downstream card of #11287) must + * offer exactly the keys this resolver reads — two hand-maintained copies + * would drift into the extractor offering a key the resolver ignores, or + * omitting one it reads. `translation.zod.ts` declares the same face; + * `translation.test.ts` pins the two in agreement. + * + * Deliberately NOT here, measured against the schema face rather than + * mirrored from the issue (#7646's report): + * + * - `description` — a screen's body text (`config.description`) is + * guidance-refused by the schema, outside the recorded #7646 ruling's + * enumeration (per-flow label, per-screen title, per-field copy). Growing + * the face is a schema-side ruled step, never a resolver-side accretion. + * - runner chrome (Cancel / Submit / the terminal toast) — the console's own + * words in every app, ruled into the console's message catalog, not the + * per-app bundle. + */ +export const FLOW_SCREEN_COPY_KEYS = ['title'] as const; + +export type FlowScreenCopyKey = typeof FLOW_SCREEN_COPY_KEYS[number]; + +/** + * The copy keys `flows..screens..fields.` carries — + * the per-FIELD face of {@link FLOW_SCREEN_COPY_KEYS}, measured against + * `ScreenFieldConfigSchema`. `help` is deliberately absent: the screen field + * declares nothing help-shaped at all, so a `help` key would parse clean and + * translate nothing (the ADR-0078 shape #6080 kept out of the page-component + * face); `options` is absent because `ScreenFieldConfig.options[].value` is + * unconstrained, so a value-keyed map cannot address the labels. Both are + * refused by name with guidance at the schema. + */ +export const FLOW_SCREEN_FIELD_COPY_KEYS = ['label', 'placeholder'] as const; + +export type FlowScreenFieldCopyKey = typeof FLOW_SCREEN_FIELD_COPY_KEYS[number]; + +function lookupFlowLabel( + bundle: TranslationBundle | undefined, + flowName: string, + opts?: ResolveOptions, +): string | undefined { + if (!bundle) return undefined; + for (const code of localeChain(opts)) { + const candidate = pickData(bundle, code)?.flows?.[flowName]?.label; + if (typeof candidate === 'string' && candidate.length > 0) return candidate; + } + return undefined; +} + +/** + * Per-screen copy for one node id, resolved across the locale chain — KEY BY + * KEY, like {@link lookupPageComponentCopy}: a partially-translated `zh` entry + * must still fall back to `en` for the keys it omits. + */ +function lookupFlowScreenCopy( + bundle: TranslationBundle | undefined, + flowName: string, + nodeId: string, + opts?: ResolveOptions, +): Partial> | undefined { + if (!bundle) return undefined; + let found: Partial> | undefined; + for (const code of localeChain(opts)) { + const entry = pickData(bundle, code)?.flows?.[flowName]?.screens?.[nodeId]; + if (!entry || typeof entry !== 'object') continue; + for (const key of FLOW_SCREEN_COPY_KEYS) { + if (found?.[key] !== undefined) continue; + const candidate = (entry as Record)[key]; + if (typeof candidate === 'string' && candidate.length > 0) { + (found ??= {})[key] = candidate; + } + } + } + return found; +} + +/** The per-field half of {@link lookupFlowScreenCopy} — same key-by-key chain walk. */ +function lookupFlowScreenFieldCopy( + bundle: TranslationBundle | undefined, + flowName: string, + nodeId: string, + fieldName: string, + opts?: ResolveOptions, +): Partial> | undefined { + if (!bundle) return undefined; + let found: Partial> | undefined; + for (const code of localeChain(opts)) { + const entry = pickData(bundle, code)?.flows?.[flowName]?.screens?.[nodeId]?.fields?.[fieldName]; + if (!entry || typeof entry !== 'object') continue; + for (const key of FLOW_SCREEN_FIELD_COPY_KEYS) { + if (found?.[key] !== undefined) continue; + const candidate = (entry as Record)[key]; + if (typeof candidate === 'string' && candidate.length > 0) { + (found ??= {})[key] = candidate; + } + } + } + return found; +} + +/** + * Resolve a translated screen heading against + * `flows..screens..title`, falling back to the literal + * `screen.title` (the authored source string the runner would draw anyway), + * and to `undefined` when the screen declares none — the same + * bundle-then-literal order every resolver on this surface follows. + * + * Takes the screen rather than a bare node id so the call site that already + * holds a `ScreenSpec` — the shape a paused `AutomationResult` carries — + * passes it as-is; `nodeId` is the one screen identifier guaranteed stable + * and present client-side (it correlates the resume back to its pause point). + */ +export function resolveFlowScreenTitle( + bundle: TranslationBundle | undefined, + flowName: string, + screen: FlowScreenLike, + opts?: ResolveOptions, +): string | undefined { + const literal = typeof screen.title === 'string' ? screen.title : undefined; + if (!bundle || !flowName || typeof screen.nodeId !== 'string' || screen.nodeId.length === 0) { + return literal; + } + return lookupFlowScreenCopy(bundle, flowName, screen.nodeId, opts)?.title ?? literal; +} + +/** + * Apply the active locale to a flow metadata document (#7646's recommendation + * B, the resolver half #11287): translates the flow's own `label` against + * `flows..label`, and — for every `type: 'screen'` node with an id — + * the screen heading and per-field copy against + * `flows..screens..{title,fields..{label,placeholder}}`. + * The input document is not mutated. + * + * **Where the translated title lands.** The bundle's `title` is written to + * `config.title` even when the author declared none: the executor builds the + * wire title as `config.title ?? node.label` (`ScreenSpec.title`), so writing + * `config.title` makes the translation reach the wire whichever of the two + * the author relied on — the one-key-covers-both rule the schema's own + * `flows` note records. The node's designer-canvas `label` itself is NOT + * overlaid: it is not on the declared vocabulary, and rewriting it would + * change the Studio canvas, not the wizard. + * + * **Schema-independent on purpose**, like every translator here: it reads + * whatever object it is handed, and overlays ONLY the declared copy keys — + * an off-spec bundle entry carrying a key the schema refuses (`description`, + * `help`) is ignored, never overlaid (the negative `translatePage` pins for + * the retired `submitLabel`). + * + * ⚠️ Deliberately NOT registered in {@link translateMetadataDocument}'s + * dispatch table: that table reaches the REST metadata boundary by itself + * (`TRANSLATABLE_METADATA_TYPES` drives `@objectstack/rest`, #3786), which + * would stand up a shipped reader of the `flows` group while its liveness + * rows are `planned` — the wiring decision (server-side vs client-side + * application) belongs to the downstream runner card of #11287, and the + * ledger flip rides that card, not this one. + */ +export function translateFlow( + flow: T, + bundle: TranslationBundle | undefined, + opts?: ResolveOptions, +): T { + if (!flow || typeof flow !== 'object') return flow; + const name = flow.name; + if (!name || !bundle) return flow; + + const label = lookupFlowLabel(bundle, name, opts); + + let nodesChanged = false; + const nodes = Array.isArray(flow.nodes) + ? flow.nodes.map((node) => { + const next = translateScreenNode(node, name, bundle, opts); + if (next !== node) nodesChanged = true; + return next; + }) + : undefined; + + if (label === undefined && !nodesChanged) return flow; + return { + ...flow, + ...(label !== undefined ? { label } : {}), + ...(nodesChanged ? { nodes } : {}), + }; +} + +/** + * The per-node half of {@link translateFlow}. Returns the node unchanged + * (same reference) when it is not a screen node, carries no id, or nothing + * resolved — so the caller can tell "translated" from "untouched" by + * identity, the discipline {@link translateInterfaceTabs} follows. + */ +function translateScreenNode( + node: FlowNodeLike, + flowName: string, + bundle: TranslationBundle | undefined, + opts?: ResolveOptions, +): FlowNodeLike { + if (!node || typeof node !== 'object' || node.type !== SCREEN_NODE_TYPE) return node; + const nodeId = typeof node.id === 'string' && node.id.length > 0 ? node.id : undefined; + if (!nodeId) return node; + + const copy = lookupFlowScreenCopy(bundle, flowName, nodeId, opts); + + const cfg = node.config && typeof node.config === 'object' ? node.config : undefined; + let fieldsChanged = false; + const fields = cfg && Array.isArray(cfg.fields) + ? cfg.fields.map((field: FlowScreenFieldLike) => { + const next = translateScreenField(field, flowName, nodeId, bundle, opts); + if (next !== field) fieldsChanged = true; + return next; + }) + : undefined; + + if (copy?.title === undefined && !fieldsChanged) return node; + return { + ...node, + config: { + ...cfg, + ...(copy?.title !== undefined ? { title: copy.title } : {}), + ...(fieldsChanged ? { fields } : {}), + }, + }; +} + +/** The per-field half of {@link translateScreenNode} — same identity discipline. */ +function translateScreenField( + field: FlowScreenFieldLike, + flowName: string, + nodeId: string, + bundle: TranslationBundle | undefined, + opts?: ResolveOptions, +): FlowScreenFieldLike { + if (!field || typeof field !== 'object') return field; + const fieldName = typeof field.name === 'string' && field.name.length > 0 ? field.name : undefined; + if (!fieldName) return field; + const copy = lookupFlowScreenFieldCopy(bundle, flowName, nodeId, fieldName, opts); + if (!copy) return field; + return { ...field, ...copy }; +} diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index 4edf914381..ba41ad55d6 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { z } from 'zod'; -import { PAGE_COMPONENT_COPY_KEYS } from './i18n-resolver'; +import { PAGE_COMPONENT_COPY_KEYS, FLOW_SCREEN_COPY_KEYS, FLOW_SCREEN_FIELD_COPY_KEYS } from './i18n-resolver'; import { FlowSchema } from '../automation/flow.zod'; import { ScreenConfigSchema, ScreenFieldConfigSchema } from '../automation/builtin-node-config.zod'; import { @@ -1142,6 +1142,38 @@ describe('translation unknown-key strictness (#4001)', () => { expect(TranslationItemSchema.safeParse({ locale: 'zh-CN', flows }).success).toBe(true); expect(TranslationItemSchema.safeParse({ locale: 'zh-CN', flows: { f: { titel: 'x' } } }).success).toBe(false); }); + + it('declares exactly the keys the resolver and the extractor act on (#11287)', () => { + // `FLOW_SCREEN_COPY_KEYS` / `FLOW_SCREEN_FIELD_COPY_KEYS` drive + // `translateFlow`'s overlay and (downstream card) the CLI's skeleton + // extraction — the same one-list-both-sides rule the #6080 pin above + // holds for `PAGE_COMPONENT_COPY_KEYS`. If this schema declared a copy + // key missing from the lists the extractor would offer a slot nothing + // reads; if a list carried one this schema lacks, `.strict()` would + // reject the very key the extractor just wrote. + for (const key of FLOW_SCREEN_COPY_KEYS) { + expect(parse({ f: { screens: { s: { [key]: 'x' } } } }).success, `screen \`${key}\` must be declared`).toBe(true); + } + for (const key of FLOW_SCREEN_FIELD_COPY_KEYS) { + expect(parse({ f: { screens: { s: { fields: { n: { [key]: 'x' } } } } } }).success, `field \`${key}\` must be declared`).toBe(true); + } + const full = (parse({ + f: { + label: 'x', + screens: { + s: { + ...Object.fromEntries(FLOW_SCREEN_COPY_KEYS.map((k) => [k, 'x'])), + fields: { n: Object.fromEntries(FLOW_SCREEN_FIELD_COPY_KEYS.map((k) => [k, 'x'])) }, + }, + }, + }, + }) as { success: true; data: any }).data.flows.f; + // The flow face is `label` + the `screens` drill; the screen face is the + // copy list + the `fields` drill; the field face is its list exactly. + expect(Object.keys(full).sort()).toEqual(['label', 'screens']); + expect(Object.keys(full.screens.s).sort()).toEqual([...FLOW_SCREEN_COPY_KEYS, 'fields'].sort()); + expect(Object.keys(full.screens.s.fields.n).sort()).toEqual([...FLOW_SCREEN_FIELD_COPY_KEYS].sort()); + }); }); it('names which action surface the key landed on', () => {