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
14 changes: 14 additions & 0 deletions .changeset/flow-screen-copy-resolver-family.md
Original file line numberDiff line numberDiff line change
@@ -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.<flow_name>.label`, `.screens.<node_id>.title`, `.screens.<node_id>.fields.<field_name>.{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.
10 changes: 10 additions & 0 deletions packages/spec/api-surface/system.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)",
Expand All@@ -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)",
Expand DownExpand Up@@ -784,6 +792,7 @@
"resolveBundleLocale (function)",
"resolveDocAudiences (function)",
"resolveDocLocale (function)",
"resolveFlowScreenTitle (function)",
"resolveMetadataFormLabels (function)",
"resolveMetadataTypeDescription (function)",
"resolveMetadataTypeLabel (function)",
Expand All@@ -810,6 +819,7 @@
"translateAction (function)",
"translateApp (function)",
"translateDashboard (function)",
"translateFlow (function)",
"translateMetadataDocument (function)",
"translateObject (function)",
"translatePage (function)",
Expand Down
10 changes: 10 additions & 0 deletions packages/spec/export-origins/system.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)",
Expand All@@ -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)",
Expand DownExpand Up@@ -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)",
Expand All@@ -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)",
Expand Down
210 changes: 210 additions & 0 deletions packages/spec/src/system/i18n-resolver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.<flow>.screens.<nodeId>.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('转化详情');
});
});
Loading
Loading