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/24915-approval-step-translation-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/spec': minor
'@objectstack/lint': minor
---

feat(spec): the `flows` translation face gains `approvals.<node_id>.label`, the step label of an `approval` node

Clause-②: yes (widening)

- **Before.** `TranslationDataSchema.flows.<flow>` carried `label`, `successMessage`, `errorMessage`, `screens.<node_id>` (screen nodes) and `refusals.<node_id>` (refusing `end` nodes), and nothing for an `approval` node. A language pack had nowhere to put an approval step's name: `approvals` was refused as an unknown key, and a `screens.<node_id>.title` key over an approval node was refused `translation-target-unknown` by `objectstack validate`, because the node is not a screen.
- **Now.** `flows.<flow>.approvals.<node_id>.label` is a declared key, addressed by the `approval` node's `id` (at any depth, regions included) and overlaying that node's own `label`, the step name an approval request shows. The entry is closed: `title`, `name`, `heading` and `stepLabel` are named as renames onto `label`, and `approval` as the rename onto the `approvals` group.
- **`objectstack validate`.** `@objectstack/lint`'s translation-reference check accepts the key over an `approval` node and refuses it, as `translation-target-unknown`, over an unknown node or a node of another type (a `screen` node is pointed at its `screens.<node_id>.title`). A `screens` or `refusals` key written over an approval node is now pointed at `approvals.<node_id>.label` instead of being told only that the node is not a screen.
- **Not yet rendered.** This is the spec half: the key parses and validates, and the approvals service does not read it yet, so an approval request still shows the authored step label. Its liveness row is `planned` until that reader lands.
- **Unchanged.** Every existing `flows` key, and every verdict on a key over a node that is not an approval node.
3 changes: 3 additions & 0 deletions content/docs/references/system/translation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ Translation data for a single object
| **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; description?: 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'` |
| **approvals** | `Record<string, { label?: string }>` | optional | Approval-step translations keyed by the node id (`FlowNode.id`) of an `approval` node |

### Nested Shape: `PlatformTranslationData.metadataForms[string]`

Expand Down Expand Up @@ -464,6 +465,7 @@ Translation data for a single object
| **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; description?: 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'` |
| **approvals** | `Record<string, { label?: string }>` | optional | Approval-step translations keyed by the node id (`FlowNode.id`) of an `approval` node |

### Nested Shape: `TranslationData.metadataForms[string]`

Expand Down Expand Up @@ -626,6 +628,7 @@ Translation data for a single object
| **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; description?: 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'` |
| **approvals** | `Record<string, { label?: string }>` | optional | Approval-step translations keyed by the node id (`FlowNode.id`) of an `approval` node |

### Nested Shape: `TranslationItem.metadataForms[string]`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.

| Dir | Sites |
|---|---|
| `system/` | 375 |
| `system/` | 376 |
121 changes: 121 additions & 0 deletions packages/lint/src/validate-translation-references.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2230,6 +2230,123 @@ describe('validateTranslationReferences — flow refusals (#22450)', () => {
});
});

describe('validateTranslationReferences — flow approval steps (`approvals.<node_id>.label`)', () => {
/**
* HotCRM's measured shape: an approval flow whose step is an `approval`
* node, beside a screen, a refusing `end` and a `decision`, so each verdict
* below is judged against the same metadata as the clean run and differs
* only in the bundle.
*/
const approvalStack = {
flows: [
{
name: 'opportunity_approval',
type: 'autolaunched',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'gate', type: 'decision', label: 'Large deal?' },
{ id: 'collect', type: 'screen', label: 'Collect', config: { fields: [{ name: 'note' }] } },
{
id: 'manager_review',
type: 'approval',
label: 'Sales Manager Review',
config: { approvers: [{ type: 'manager' }] },
},
{ id: 'refuse', type: 'end', label: 'Refused', config: { outcome: 'refused', message: 'Refused.' } },
],
},
],
};
const bundle = (flows: unknown) => ({ ...approvalStack, translations: [{ 'zh-CN': { flows } }] });

it('reports nothing for the label of an `approval` node — the key the step has no other home for', () => {
expect(
validateTranslationReferences(bundle({ opportunity_approval: { approvals: { manager_review: { label: '销售经理审批' } } } })),
).toEqual([]);
});

it('controls: the screen and refusal keys of the same flow still resolve beside it', () => {
expect(
validateTranslationReferences(
bundle({
opportunity_approval: {
screens: { collect: { title: '收集', fields: { note: { label: '备注' } } } },
refusals: { refuse: { message: '已拒绝。' } },
approvals: { manager_review: { label: '销售经理审批' } },
},
}),
),
).toEqual([]);
});

it('refuses an approval key on a node of another type, by name', () => {
const findings = validateTranslationReferences(bundle({ opportunity_approval: { approvals: { gate: { label: 'x' } } } }));
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN);
expect(findings[0].severity).toBe('error');
expect(findings[0].path).toBe('translations[0]["zh-CN"].flows.opportunity_approval.approvals.gate');
expect(findings[0].message).toBe(
'Translations are keyed to approval step "gate", which flow "opportunity_approval" declares as a ' +
'`decision` node, not an `approval`, so nothing resolves this key.',
);
expect(findings[0].hint).toBe('Approval-step translations are keyed by the `id` of an `approval` node.');
});

it('refuses an approval key on a screen node, pointing at the screen group', () => {
const findings = validateTranslationReferences(bundle({ opportunity_approval: { approvals: { collect: { label: 'x' } } } }));
expect(findings).toHaveLength(1);
expect(findings[0].path).toBe('translations[0]["zh-CN"].flows.opportunity_approval.approvals.collect');
expect(findings[0].message).toBe(
'Translations are keyed to approval step "collect", which flow "opportunity_approval" declares as a ' +
'`screen` node; its heading is keyed `screens.collect.title`.',
);
});

it('refuses an approval key over an UNKNOWN node, with the nearest approval node offered', () => {
const findings = validateTranslationReferences(
bundle({ opportunity_approval: { approvals: { manager_reveiw: { label: 'x' } } } }),
);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN);
expect(findings[0].path).toBe('translations[0]["zh-CN"].flows.opportunity_approval.approvals.manager_reveiw');
expect(findings[0].message).toContain(
'Translations are keyed to approval step "manager_reveiw", which flow "opportunity_approval" declares no ' +
'node for, so the step keeps its source-locale label.',
);
expect(findings[0].message).toContain('Did you mean "manager_review"?');
});

it('points a `screens` key written over an approval node at the `approvals` group (the measured HotCRM key)', () => {
const findings = validateTranslationReferences(
bundle({ opportunity_approval: { screens: { manager_review: { title: '销售经理审批' } } } }),
);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN);
expect(findings[0].path).toBe('translations[0]["zh-CN"].flows.opportunity_approval.screens.manager_review');
expect(findings[0].message).toBe(
'Translations are keyed to screen "manager_review", which flow "opportunity_approval" declares as an ' +
'`approval` node; its step label is keyed `approvals.manager_review.label`.',
);
});

it('points a `refusals` key written over an approval node at the `approvals` group', () => {
const findings = validateTranslationReferences(
bundle({ opportunity_approval: { refusals: { manager_review: { message: 'x' } } } }),
);
expect(findings).toHaveLength(1);
expect(findings[0].message).toBe(
'Translations are keyed to refusal "manager_review", which flow "opportunity_approval" declares as an ' +
'`approval` node; its step label is keyed `approvals.manager_review.label`.',
);
});

it('control: a `screens` key over a non-approval, non-screen node keeps its own diagnosis', () => {
const findings = validateTranslationReferences(bundle({ opportunity_approval: { screens: { gate: { title: 'x' } } } }));
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('declares as a `decision` node, not a `screen`');
});
});

describe('validateTranslationReferences — flow toasts and screen-field options (#22507)', () => {
/**
* hotclm's measured shape, trimmed: a screen flow that authors a completion
Expand Down Expand Up @@ -3906,6 +4023,10 @@ describe('[#22161] one-line verdicts — translation-target-unknown, translation
'`end` node that completes',
'node, not a refusing `end`',
'a node id flow',
'node, not an `approval`',
'`screen` node; its heading is keyed',
'declares no node for, so the step keeps',
'`approval` node; its step label is keyed',
'Translations are keyed to parameter "',
'does not declare in `outcomeMessages`',
'declares no `outcomeMessages` at all',
Expand Down
85 changes: 67 additions & 18 deletions packages/lint/src/validate-translation-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,14 @@
* are each an orphan, with its own diagnosis. The message's `{{ }}` holes are
* the schema's to judge (`TranslationDataSchema`, the one text-slot judge).
*
* `flows.<name>.approvals.<node_id>.label` is judged the same way: the node id
* must name an `approval` node (`APPROVAL_NODE_TYPE`), at any depth. A node of
* another type and an id the flow does not declare are each an orphan, with
* its own diagnosis — a `screen` node pointed at its `screens.<node_id>.title`.
* The reverse slip, a `screens` or `refusals` key written over an approval
* node, is pointed at `approvals.<node_id>.label` rather than told only that
* the node is not a screen.
*
* `flows.<name>.successMessage` / `.errorMessage` (#22507), the terminal
* toasts, are judged against the flow itself: `translateFlow` overlays a toast
* only where the flow AUTHORS one, so a translated toast over a flow that
Expand Down Expand Up @@ -198,7 +206,7 @@
*/

import { expandViewContainer } from '@objectstack/spec';
import { flowScreenFieldOptionKey } from '@objectstack/spec/automation';
import { APPROVAL_NODE_TYPE, flowScreenFieldOptionKey } from '@objectstack/spec/automation';
import {
FLOW_TERMINAL_MESSAGE_KEYS,
hasPlatformObjectPrefix,
Expand Down Expand Up @@ -394,6 +402,11 @@ interface FlowFacts {
* renders no message, so a key over one is an orphan.
*/
refusals: Set<string>;
/**
* The ids of the `approval` nodes — the nodes a
* `flows.<name>.approvals.<node_id>` key addresses, at any depth.
*/
approvals: Set<string>;
/**
* [#22507] The terminal toasts the flow AUTHORS (`successMessage` /
* `errorMessage` declared as a string) — the only ones a
Expand Down Expand Up @@ -1423,6 +1436,7 @@ function buildUniverse(stack: AnyRec): Universe {
if (!ownDeclaration && flows.has(flowName)) return;
const screens = new Map<string, ScreenFacts>();
const refusals = new Set<string>();
const approvals = new Set<string>();
const otherNodes = new Map<string, string>();
const messages = new Set<string>(FLOW_TERMINAL_MESSAGE_KEYS.filter((key) => typeof flow[key] === 'string'));
for (const { node } of walkFlowNodes(flow, '')) {
Expand All @@ -1436,6 +1450,7 @@ function buildUniverse(stack: AnyRec): Universe {
if (nodeType === END_NODE_TYPE && isRec(node.config) && node.config.outcome === REFUSED_OUTCOME) {
refusals.add(nodeId);
}
if (nodeType === APPROVAL_NODE_TYPE) approvals.add(nodeId);
continue;
}
const config = isRec(node.config) ? node.config : undefined;
Expand All @@ -1455,7 +1470,7 @@ function buildUniverse(stack: AnyRec): Universe {
hasDescription: typeof config?.description === 'string' && config.description.length > 0,
});
}
flows.set(flowName, { screens, refusals, messages, otherNodes });
flows.set(flowName, { screens, refusals, approvals, messages, otherNodes });
};
for (const flow of recordsOf(stack.flows)) collectFlowRecord(flow, { ownDeclaration: true });
for (const flow of artifactProvidedRecords(stack, 'flows')) {
Expand Down Expand Up @@ -1923,12 +1938,14 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind
orphan(
`${inLocale} · flow "${flowName}" · screen "${nodeId}"`,
screenPath,
otherType
? `Translations are keyed to screen "${nodeId}", which flow "${flowName}" ` +
`declares as a \`${otherType}\` node, not a \`screen\`, so nothing resolves these keys.`
: `Translations are keyed to screen "${nodeId}", which flow "${flowName}" ` +
`declares no screen node for, so the wizard step keeps its source-locale heading.` +
suggest(nodeId, flow.screens.keys()),
otherType === APPROVAL_NODE_TYPE
? approvalRedirect('screen', nodeId, flowName)
: otherType
? `Translations are keyed to screen "${nodeId}", which flow "${flowName}" ` +
`declares as a \`${otherType}\` node, not a \`screen\`, so nothing resolves these keys.`
: `Translations are keyed to screen "${nodeId}", which flow "${flowName}" ` +
`declares no screen node for, so the wizard step keeps its source-locale heading.` +
suggest(nodeId, flow.screens.keys()),
`Screen translations are keyed by the node's \`id\` (the client's ` +
`\`ScreenSpec.nodeId\`), not its \`label\`.`,
);
Expand Down Expand Up @@ -1985,27 +2002,59 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind
orphan(
`${inLocale} · flow "${flowName}" · refusal "${nodeId}"`,
`${flowPath}.refusals.${nodeId}`,
nodeType === END_NODE_TYPE
? `Translations are keyed to refusal "${nodeId}", which flow "${flowName}" declares as an ` +
`\`end\` node that completes, not one declaring \`outcome: 'refused'\`, so nothing ` +
`resolves this key.`
: nodeType
? `Translations are keyed to refusal "${nodeId}", which flow "${flowName}" declares as a ` +
`\`${nodeType}\` node, not a refusing \`end\`, so nothing resolves this key.`
: `Translations are keyed to refusal "${nodeId}", a node id flow "${flowName}" does not ` +
`declare, so the refusal keeps its source-locale message.` +
suggest(nodeId, flow.refusals),
nodeType === APPROVAL_NODE_TYPE
? approvalRedirect('refusal', nodeId, flowName)
: nodeType === END_NODE_TYPE
? `Translations are keyed to refusal "${nodeId}", which flow "${flowName}" declares as an ` +
`\`end\` node that completes, not one declaring \`outcome: 'refused'\`, so nothing ` +
`resolves this key.`
: nodeType
? `Translations are keyed to refusal "${nodeId}", which flow "${flowName}" declares as a ` +
`\`${nodeType}\` node, not a refusing \`end\`, so nothing resolves this key.`
: `Translations are keyed to refusal "${nodeId}", a node id flow "${flowName}" does not ` +
`declare, so the refusal keeps its source-locale message.` +
suggest(nodeId, flow.refusals),
`Refusal translations are keyed by the \`id\` of an \`end\` node declaring ` +
`\`outcome: 'refused'\`.`,
);
}
// ── flows.<name>.approvals.<node_id> ──────────────────────────────
for (const nodeId of Object.keys(asRecord(rawFlow.approvals))) {
if (flow.approvals.has(nodeId)) continue;
const nodeType = flow.screens.has(nodeId) ? SCREEN_NODE_TYPE : flow.otherNodes.get(nodeId);
orphan(
`${inLocale} · flow "${flowName}" · approval step "${nodeId}"`,
`${flowPath}.approvals.${nodeId}`,
nodeType === SCREEN_NODE_TYPE
? `Translations are keyed to approval step "${nodeId}", which flow "${flowName}" declares as a ` +
`\`screen\` node; its heading is keyed \`screens.${nodeId}.title\`.`
: nodeType
? `Translations are keyed to approval step "${nodeId}", which flow "${flowName}" declares as a ` +
`\`${nodeType}\` node, not an \`approval\`, so nothing resolves this key.`
: `Translations are keyed to approval step "${nodeId}", which flow "${flowName}" declares no ` +
`node for, so the step keeps its source-locale label.` +
suggest(nodeId, flow.approvals),
`Approval-step translations are keyed by the \`id\` of an \`approval\` node.`,
);
}
}
}
}

return findings;
}

/**
* The verdict for a `screens` / `refusals` key written over an `approval`
* node: the step's one translatable string is its label, under `approvals`.
*/
function approvalRedirect(group: 'screen' | 'refusal', nodeId: string, flowName: string): string {
return (
`Translations are keyed to ${group} "${nodeId}", which flow "${flowName}" declares as an ` +
`\`approval\` node; its step label is keyed \`approvals.${nodeId}.label\`.`
);
}

/** `v` as a record of sub-nodes, or an empty record when absent/malformed. */
function asRecord(v: unknown): Record<string, unknown> {
return isRec(v) ? v : {};
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/liveness/state-counts/translation.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them.

| Type | live | exp | elsewhere | dead | planned | classified |
|---|---|---|---|---|---|---|
| `translation` | 29 | 0 | 0 | 0 | 0 | 29 |
| `translation` | 29 | 0 | 0 | 0 | 1 | 30 |
Loading
Loading