fix: Add Switch fallback output guidance for workflow builder (#30449)

This commit is contained in:
Albert Alises
2026-05-15 09:33:25 +02:00
committed by GitHub
parent e80ccd84d2
commit 17b64da0f0
15 changed files with 480 additions and 18 deletions
@@ -125,5 +125,52 @@ Expected Output:
]
}
}
#### Example 3: Add Catch-All Branch
Current Parameters: { "mode": "rules" }
Requested Changes: Route urgent and normal priorities, and send everything else to a fallback branch
Expected Output:
{
"mode": "rules",
"rules": {
"values": [
{
"conditions": {
"options": { "caseSensitive": false, "leftValue": "", "typeValidation": "strict" },
"conditions": [
{
"leftValue": "={{ $json.priority }}",
"rightValue": "urgent",
"operator": { "type": "string", "operation": "equals" }
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "Urgent"
},
{
"conditions": {
"options": { "caseSensitive": false, "leftValue": "", "typeValidation": "strict" },
"conditions": [
{
"leftValue": "={{ $json.priority }}",
"rightValue": "normal",
"operator": { "type": "string", "operation": "equals" }
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "Normal"
}
]
},
"options": {
"fallbackOutput": "extra",
"renameFallbackOutput": "Fallback"
}
}
`,
};
@@ -330,13 +330,14 @@ CONNECTION EXAMPLES:
MULTI-OUTPUT NODES (sourceOutputIndex):
- IF node: output 0 = true branch, output 1 = false branch
- Switch node: outputs 0 to N-1 based on configured rules, output N = default/fallback
- Switch node: outputs 0 to N-1 based on configured rules. Output N exists as the default/fallback branch only when parameters.options.fallbackOutput is set to 'extra'. Without that option, unmatched items are dropped unless fallbackOutput routes them to an existing rule output.
ERROR OUTPUT CONNECTIONS (onError: 'continueErrorOutput'):
When a node has nodeSettings.onError = 'continueErrorOutput', it gains an ADDITIONAL error output appended as the LAST index:
- Single-output node (HTTP Request): output 0 = success, output 1 = error
- IF node (2 outputs) + error handling: output 0 = true, output 1 = false, output 2 = error
- Switch node (N outputs) + error handling: outputs 0 to N-1 = branches, output N = error
- Switch node without extra fallback + error handling: outputs 0 to N-1 = branches, output N = error
- Switch node with extra fallback + error handling: outputs 0 to N-1 = branches, output N = fallback, output N+1 = error
Example: HTTP Request with continueErrorOutput → success at index 0, error at index 1
Example: IF with continueErrorOutput → true at 0, false at 1, error at 2`,
@@ -25,6 +25,7 @@ import { noInvalidFromAi } from './no-invalid-from-ai';
import { noUnnecessaryCodeNodes } from './no-unnecessary-code-nodes';
import { noUnreachableNodes } from './no-unreachable-nodes';
import { responseMatchesWorkflowChanges } from './response-matches-workflow-changes';
import { switchFallbackOutputEnabled } from './switch-fallback-output-enabled';
import { toolsHaveParameters } from './tools-have-parameters';
import { validDataFlow } from './valid-data-flow';
import { validFieldReferences } from './valid-field-references';
@@ -52,6 +53,7 @@ export const DETERMINISTIC_CHECKS: BinaryCheck[] = [
noUnreachableNodes,
inboundTriggerAuthDefaults,
httpGenericAuthTypeMatchesPrompt,
switchFallbackOutputEnabled,
validNodeConfig,
];
@@ -0,0 +1,92 @@
import { switchFallbackOutputEnabled } from './switch-fallback-output-enabled';
import type { WorkflowResponse } from '../../clients/n8n-client';
const switchRules = {
values: [
{
outputKey: 'Urgent',
conditions: {
options: { caseSensitive: false, leftValue: '', typeValidation: 'strict' },
conditions: [
{
leftValue: '={{ $json.priority }}',
rightValue: 'urgent',
operator: { type: 'string', operation: 'equals' },
},
],
combinator: 'and',
},
},
{
outputKey: 'Normal',
conditions: {
options: { caseSensitive: false, leftValue: '', typeValidation: 'strict' },
conditions: [
{
leftValue: '={{ $json.priority }}',
rightValue: 'normal',
operator: { type: 'string', operation: 'equals' },
},
],
combinator: 'and',
},
},
],
};
function workflowWithSwitch(options?: Record<string, unknown>): WorkflowResponse {
return {
id: 'wf-1',
name: 'Switch fallback test',
active: false,
versionId: 'version-1',
nodes: [
{
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
parameters: {},
},
{
name: 'Route Priority',
type: 'n8n-nodes-base.switch',
typeVersion: 3.4,
parameters: {
mode: 'rules',
rules: switchRules,
...(options ? { options } : {}),
},
},
{
name: 'Fallback',
type: 'n8n-nodes-base.noOp',
parameters: {},
},
],
connections: {
'Manual Trigger': {
main: [[{ node: 'Route Priority', type: 'main', index: 0 }]],
},
'Route Priority': {
main: [[], [], [{ node: 'Fallback', type: 'main', index: 0 }]],
},
},
};
}
describe('switchFallbackOutputEnabled', () => {
it('fails when a Switch fallback branch is wired without fallbackOutput extra', async () => {
const result = await switchFallbackOutputEnabled.run(workflowWithSwitch(), { prompt: '' });
expect(result.pass).toBe(false);
expect(result.comment).toContain("options.fallbackOutput is set to 'extra'");
});
it('passes when fallbackOutput extra creates the fallback branch', async () => {
const result = await switchFallbackOutputEnabled.run(
workflowWithSwitch({ fallbackOutput: 'extra' }),
{ prompt: '' },
);
expect(result).toEqual({ pass: true });
});
});
@@ -0,0 +1,42 @@
import { validateWorkflow } from '@n8n/workflow-sdk';
import type { WorkflowJSON } from '@n8n/workflow-sdk';
import type { WorkflowResponse } from '../../clients/n8n-client';
import type { BinaryCheck } from '../types';
function toWorkflowJson(workflow: WorkflowResponse): WorkflowJSON {
return {
name: workflow.name,
nodes: (workflow.nodes ?? []).map((n, i) => ({
id: String(i),
name: n.name,
type: n.type,
typeVersion: n.typeVersion ?? 1,
parameters: n.parameters ?? {},
position: [0, 0],
...(n.onError ? { onError: n.onError } : {}),
})),
connections: workflow.connections,
} as unknown as WorkflowJSON;
}
export const switchFallbackOutputEnabled: BinaryCheck = {
name: 'switch_fallback_output_enabled',
description: 'Switch fallback branches are only wired when the extra fallback output exists',
kind: 'deterministic',
run(workflow: WorkflowResponse) {
const result = validateWorkflow(toWorkflowJson(workflow), {
allowNoTrigger: true,
allowDisconnectedNodes: true,
validateSchema: false,
});
const warnings = result.warnings.filter((w) => w.code === 'SWITCH_FALLBACK_OUTPUT_DISABLED');
if (warnings.length === 0) return { pass: true };
return {
pass: false,
comment: warnings.map((w) => w.message).join('; '),
};
},
};
@@ -50,6 +50,7 @@ export interface WorkflowNodeResponse {
type: string;
typeVersion?: number;
parameters?: Record<string, unknown>;
onError?: 'stopWorkflow' | 'continueRegularOutput' | 'continueErrorOutput';
disabled?: boolean;
credentials?: Record<string, unknown>;
}
@@ -0,0 +1,4 @@
{
"id": "switch-fallback-routing",
"prompt": "Build a workflow that receives a webhook POST with a JSON body containing 'priority' and 'message'. Route priority 'urgent' items to Slack channel #incidents, priority 'normal' items to Gmail at ops@example.com, and send every unmatched priority to Slack channel #triage as the default fallback branch. Configure all nodes completely and don't ask for credentials."
}
@@ -128,7 +128,7 @@ ${PLACEHOLDERS_RULE}
## Mandatory Process
1. **Research**: If the workflow fits a known category (notification, chatbot, scheduling, data_transformation, etc.), call \`nodes(action="suggested")\` first for curated recommendations. Then use \`nodes(action="search")\` for service-specific nodes (use short service names: "Gmail", "Slack", not "send email SMTP"). The results include \`discriminators\` (available resources and operations) for nodes that need them. Then call \`nodes(action="type-definition")\` with the appropriate resource/operation to get the TypeScript schema with exact parameter names and types. **Pay attention to @builderHint annotations** in search results and type definitions — they prevent common configuration mistakes.
2. **Build**: Write TypeScript SDK code and call \`build-workflow\`. Follow the SDK patterns below exactly.
3. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch \`outputKey\` has a matching \`.onCase('<outputKey>')\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria.
3. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch rule output is wired by zero-based \`.onCase(index, target)\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria.
4. **Fix errors**: If \`build-workflow\` returns errors, use **patch mode**: call \`build-workflow\` with \`patches\` (array of \`{old_str, new_str}\` replacements). Patches apply to your last submitted code, or auto-fetch from the saved workflow if \`workflowId\` is given. Much faster than resending full code.
5. **Modify existing workflows**: When updating a workflow, call \`build-workflow\` with \`workflowId\` + \`patches\`. The tool fetches the current code and applies your patches. Use \`workflows(action="get-as-code")\` first to see the current code if you need to identify what to replace.
6. **Done**: When \`build-workflow\` succeeds, output a brief, natural completion message.
@@ -419,7 +419,7 @@ n8n normalizes column names to snake_case (e.g., \`dayName\` → \`day_name\`).
5. **Write workflow code** to \`${workspaceRoot}/src/workflow.ts\`.
6. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch \`outputKey\` has a matching \`.onCase('<outputKey>')\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria.
6. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch rule output is wired by zero-based \`.onCase(index, target)\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria.
7. **Validate with tsc**: Run the TypeScript compiler for real type checking:
\`\`\`
@@ -449,7 +449,7 @@ Follow the **Compositional Workflow Pattern** above. The process becomes:
c. Submit the chunk: \`submit-workflow\` with \`filePath\` pointing to the chunk file. Test via \`executions(action="run")\`.
d. Fix if needed (max 2 submission fix attempts per chunk).
6. **Write the main workflow** in \`${workspaceRoot}/src/workflow.ts\` that composes chunks via \`executeWorkflow\` nodes, referencing each chunk's workflow ID.
7. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch \`outputKey\` has a matching \`.onCase('<outputKey>')\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria.
7. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch rule output is wired by zero-based \`.onCase(index, target)\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria.
8. **Submit** the main workflow.
9. **Done**: Output ONE sentence summarizing what was built, including the workflow ID and any known issues.
@@ -87,7 +87,7 @@ flowchart LR
Use Switch node as primary traffic controller:
- Configure cases for each classification value
- Always define Default case for unexpected values
- Always configure an extra fallback output for unexpected values
- Each item follows exactly one branch
Avoid parallel IF nodes that could match multiple conditions - use Switch node.
@@ -102,7 +102,7 @@ Avoid parallel IF nodes that could match multiple conditions - use Switch node.
**Switch** (n8n-nodes-base.switch):
- Purpose: Multi-way branching based on field values
- Use when: Multiple categories (3+ outcomes)
- Configure Default output for unmatched items
- Configure options.fallbackOutput: 'extra' for unmatched items
**Merge** (n8n-nodes-base.merge):
- Purpose: Consolidate branches for unified logging
@@ -125,9 +125,9 @@ For all AI nodes (Text Classifier, AI Agent):
## Common Pitfalls to Avoid
### No Default Path
**Problem**: Every Switch must have a Default output. Unmatched items should go to manual review or logging, never drop silently.
**Problem**: Switch nodes do not create a default output unless options.fallbackOutput is set to 'extra'. Unmatched items should go to manual review or logging, never drop silently.
**Solution**: Always configure Default case to route unclassified items to a fallback action (e.g., manual review queue, admin notification)
**Solution**: Configure options.fallbackOutput: 'extra' and wire that fallback output to a fallback action (e.g., manual review queue, admin notification).
### No "Other" Branch in Text Classifier
**Problem**: Items that don't match any category get dropped if "When No Clear Match" isn't set.
@@ -13,4 +13,11 @@ describe('WORKFLOW_SDK_PATTERNS', () => {
expect.stringContaining('parameters: {}'),
);
});
it('uses current Switch fallback syntax', () => {
expect(WORKFLOW_SDK_PATTERNS).not.toContain('.onDefault(');
expect(WORKFLOW_SDK_PATTERNS).not.toMatch(/\.onCase\(['"]/);
expect(WORKFLOW_SDK_PATTERNS).toContain("fallbackOutput: 'extra'");
expect(WORKFLOW_SDK_PATTERNS).toContain('.onCase(2, archive)');
});
});
@@ -210,7 +210,8 @@ const routeByPriority = switchCase({
{ outputKey: 'urgent', conditions: { options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' }, conditions: [{ leftValue: expr('{{ $json.priority }}'), operator: { type: 'string', operation: 'equals' }, rightValue: 'urgent' }], combinator: 'and' } },
{ outputKey: 'normal', conditions: { options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' }, conditions: [{ leftValue: expr('{{ $json.priority }}'), operator: { type: 'string', operation: 'equals' }, rightValue: 'normal' }], combinator: 'and' } },
]
}
},
options: { fallbackOutput: 'extra', renameFallbackOutput: 'Fallback' }
}
}
});
@@ -218,9 +219,9 @@ const routeByPriority = switchCase({
export default workflow('id', 'name')
.add(startTrigger)
.to(routeByPriority
.onCase('urgent', processUrgent.to(notifyTeam.to(escalate)))
.onCase('normal', processNormal)
.onDefault(archive));
.onCase(0, processUrgent.to(notifyTeam.to(escalate)))
.onCase(1, processNormal)
.onCase(2, archive));
\`\`\`
</multi_way_routing>
@@ -42,6 +42,7 @@ export type ValidationErrorCode =
| 'UNSUPPORTED_SUBNODE_INPUT'
| 'MISSING_REQUIRED_INPUT'
| 'INVALID_OUTPUT_FOR_MODE'
| 'SWITCH_FALLBACK_OUTPUT_DISABLED'
| 'MAX_NODES_EXCEEDED'
| 'INVALID_EXPRESSION_PATH'
| 'PARTIAL_EXPRESSION_PATH'
@@ -190,6 +191,7 @@ interface NodeJSON {
typeVersion?: number | string;
position?: [number, number];
parameters?: Record<string, unknown>;
onError?: string;
}
/**
@@ -507,6 +509,10 @@ export function validateWorkflow(
validatePlaceholderSlots(json, options.nodeTypesProvider, errors);
}
// Switch fallback output validation does not need node metadata. It is derived from
// the Switch node's dynamic output contract in rules mode.
validateSwitchFallbackOutputConnections(json, warnings);
// Merge node input-count consistency
checkMergeNodeInputCount(json, warnings);
@@ -1019,6 +1025,81 @@ function validateOutputUsage(
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function getSwitchRulesCount(parameters: Record<string, unknown> | undefined): number {
const rules = parameters?.rules;
if (!isRecord(rules)) return 0;
const values = rules.values;
if (Array.isArray(values)) return values.length;
const legacyRules = rules.rules;
if (Array.isArray(legacyRules)) return legacyRules.length;
return 0;
}
function getSwitchFallbackOutput(parameters: Record<string, unknown> | undefined): unknown {
const options = parameters?.options;
if (!isRecord(options)) return undefined;
return options.fallbackOutput;
}
function hasOutputConnections(
outputs: Array<Array<{ node: string; type: string; index: number }> | null>,
outputIndex: number,
): boolean {
const output = outputs[outputIndex];
return Array.isArray(output) && output.length > 0;
}
/**
* Validate that Switch fallback branches are only connected when the node
* actually exposes an extra fallback output.
*/
function validateSwitchFallbackOutputConnections(
json: WorkflowJSON,
warnings: ValidationWarning[],
): void {
for (const sourceNode of json.nodes) {
if (!sourceNode.name || sourceNode.type !== 'n8n-nodes-base.switch') continue;
const mode = sourceNode.parameters?.mode;
if (mode !== undefined && mode !== 'rules') continue;
const outgoing = json.connections[sourceNode.name];
const mainOutputs = outgoing?.main;
if (!Array.isArray(mainOutputs)) continue;
const rulesCount = getSwitchRulesCount(sourceNode.parameters);
const fallbackOutput = getSwitchFallbackOutput(sourceNode.parameters);
if (fallbackOutput === 'extra') continue;
for (let outputIndex = rulesCount; outputIndex < mainOutputs.length; outputIndex++) {
if (!hasOutputConnections(mainOutputs, outputIndex)) continue;
const isErrorOutput =
sourceNode.onError === 'continueErrorOutput' && outputIndex === rulesCount;
if (isErrorOutput) continue;
warnings.push(
new ValidationWarning(
'SWITCH_FALLBACK_OUTPUT_DISABLED',
`Switch node '${sourceNode.name}' has a connection from output ${outputIndex}, but rules mode only creates fallback output ${rulesCount} when options.fallbackOutput is set to 'extra'. Set options.fallbackOutput to 'extra' before wiring a catch-all branch, or route unmatched items to an existing rule output with a numeric fallbackOutput value.`,
sourceNode.name,
'options.fallbackOutput',
undefined,
'major',
),
);
}
}
}
/**
* Reject `placeholder()` markers found in parameter slots whose property
* description carries `builderHint.placeholderSupported === false`.
@@ -1,6 +1,6 @@
import { validateWorkflow, ValidationError } from '.';
import { setupTestSchemas, teardownTestSchemas } from './test-schema-setup';
import type { NodeInstance } from '../types/base';
import type { NodeInstance, WorkflowJSON } from '../types/base';
import { workflow } from '../workflow-builder';
import { node, trigger, sticky } from '../workflow-builder/node-builders/node-builder';
import { languageModel, tool } from '../workflow-builder/node-builders/subnode-builders';
@@ -2988,6 +2988,154 @@ describe('Validation', () => {
});
});
describe('SWITCH_FALLBACK_OUTPUT_DISABLED validation', () => {
const switchRules = {
values: [
{
outputKey: 'Urgent',
conditions: {
options: { caseSensitive: false, leftValue: '', typeValidation: 'strict' },
conditions: [
{
leftValue: '={{ $json.priority }}',
rightValue: 'urgent',
operator: { type: 'string', operation: 'equals' },
},
],
combinator: 'and',
},
},
{
outputKey: 'Normal',
conditions: {
options: { caseSensitive: false, leftValue: '', typeValidation: 'strict' },
conditions: [
{
leftValue: '={{ $json.priority }}',
rightValue: 'normal',
operator: { type: 'string', operation: 'equals' },
},
],
combinator: 'and',
},
},
],
};
function createSwitchWorkflow(args: {
switchOptions?: Record<string, unknown>;
switchOnError?: 'continueErrorOutput';
fallbackOutputIndex?: number;
}): WorkflowJSON {
const fallbackOutputIndex = args.fallbackOutputIndex ?? 2;
return {
id: 'test',
name: 'Test',
nodes: [
{
id: 'trigger-1',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: 'switch-1',
name: 'Route Priority',
type: 'n8n-nodes-base.switch',
typeVersion: 3.4,
position: [200, 0],
parameters: {
mode: 'rules',
rules: switchRules,
...(args.switchOptions ? { options: args.switchOptions } : {}),
},
...(args.switchOnError ? { onError: args.switchOnError } : {}),
},
{
id: 'case-1',
name: 'Handle Urgent',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [400, -100],
parameters: {},
},
{
id: 'case-2',
name: 'Handle Normal',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [400, 0],
parameters: {},
},
{
id: 'fallback-1',
name: 'Handle Fallback',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [400, 100],
parameters: {},
},
],
connections: {
'Manual Trigger': {
main: [[{ node: 'Route Priority', type: 'main', index: 0 }]],
},
'Route Priority': {
main: [
[{ node: 'Handle Urgent', type: 'main', index: 0 }],
[{ node: 'Handle Normal', type: 'main', index: 0 }],
...Array.from({ length: Math.max(0, fallbackOutputIndex - 2) }, () => []),
[{ node: 'Handle Fallback', type: 'main', index: 0 }],
],
},
},
};
}
function getSwitchFallbackWarnings(workflowJson: WorkflowJSON) {
return validateWorkflow(workflowJson).warnings.filter(
(w) => w.code === 'SWITCH_FALLBACK_OUTPUT_DISABLED',
);
}
it('warns when the fallback output is connected without fallbackOutput extra', () => {
const warnings = getSwitchFallbackWarnings(createSwitchWorkflow({}));
expect(warnings).toHaveLength(1);
expect(warnings[0].nodeName).toBe('Route Priority');
expect(warnings[0].parameterPath).toBe('options.fallbackOutput');
expect(warnings[0].message).toContain("options.fallbackOutput is set to 'extra'");
expect(warnings[0].violationLevel).toBe('major');
});
it('does not warn when fallbackOutput extra creates the fallback output', () => {
const warnings = getSwitchFallbackWarnings(
createSwitchWorkflow({ switchOptions: { fallbackOutput: 'extra' } }),
);
expect(warnings).toHaveLength(0);
});
it('warns when numeric fallbackOutput is used with an extra fallback connection', () => {
const warnings = getSwitchFallbackWarnings(
createSwitchWorkflow({ switchOptions: { fallbackOutput: 1 } }),
);
expect(warnings).toHaveLength(1);
});
it('does not mistake the error output for a fallback branch', () => {
const warnings = getSwitchFallbackWarnings(
createSwitchWorkflow({ switchOnError: 'continueErrorOutput' }),
);
expect(warnings).toHaveLength(0);
});
});
describe('validatePlaceholderSlots (builderHint.placeholderSupported=false)', () => {
const mockNodeTypesProviderWithPlaceholderOptOut = {
getByNameAndVersion: (_type: string, _version?: number) => ({
@@ -89,6 +89,10 @@ const routeByPriority = switchCase({
}
}
]
},
options: {
fallbackOutput: 'extra',
renameFallbackOutput: 'Fallback'
}
}
}
@@ -97,9 +101,9 @@ const routeByPriority = switchCase({
export default workflow('id', 'name')
.add(startTrigger)
.to(routeByPriority
.onCase('urgent', processUrgent.to(notifyTeam))
.onCase('normal', processNormal)
.onDefault(archive));
.onCase(0, processUrgent.to(notifyTeam))
.onCase(1, processNormal)
.onCase(2, archive));
</pattern>
</patterns>`,
},
@@ -176,7 +180,7 @@ export default workflow('id', 'name')
type: 'fixedCollection',
builderHint: {
propertyHint:
"Use `rules.values` (NOT `rules.rules`). Each rule needs `outputKey` and a complete `conditions` object with these three sibling keys: `combinator` ('and' | 'or'), `conditions` (array of condition objects), `options` (`{ caseSensitive, leftValue, typeValidation }`). Same shape as IF. Each `outputKey` you define must be wired via `.onCase('<outputKey>')` to the intended downstream node — unwired cases silently drop their items.",
"Use `rules.values` (NOT `rules.rules`). Each rule needs `outputKey` and a complete `conditions` object with these three sibling keys: `combinator` ('and' | 'or'), `conditions` (array of condition objects), `options` (`{ caseSensitive, leftValue, typeValidation }`). Same shape as IF. Wire rule outputs by zero-based index with `.onCase(index, target)`; `outputKey` is the visible output label, not the `.onCase()` argument. Unwired cases silently drop their items.",
},
typeOptions: {
multipleValues: true,
@@ -283,6 +287,10 @@ export default workflow('id', 'name')
loadOptionsDependsOn: ['rules.values', '/rules', '/rules.values'],
loadOptionsMethod: 'getFallbackOutputOptions',
},
builderHint: {
propertyHint:
"Set this to `'extra'` before wiring a catch-all/default branch. In rules mode, `'extra'` creates a fallback output at index `rules.values.length`; default `'none'` creates no fallback output and drops unmatched items. Numeric values route unmatched items to an existing rule output and do not create a new port.",
},
default: 'none',
// eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-dynamic-options
description:
@@ -309,6 +317,10 @@ export default workflow('id', 'name')
type: 'string',
placeholder: 'e.g. Fallback',
default: '',
builderHint: {
propertyHint:
"Only labels the extra fallback output. Use it together with `fallbackOutput: 'extra'`; it does not create a fallback output by itself.",
},
displayOptions: {
show: {
fallbackOutput: ['extra'],
@@ -67,6 +67,30 @@ describe('SwitchV3 Node', () => {
const switchNode = new SwitchV3(baseDescription);
expect(switchNode.description.version).toContain(3.3);
});
it('should document SDK-discoverable fallback output metadata', () => {
const switchNode = new SwitchV3(baseDescription);
const sdkExample = switchNode.description.builderHint?.extraTypeDefContent?.[0].content ?? '';
const optionsProperty = switchNode.description.properties.find(
(prop) => prop.name === 'options',
);
const optionParameters = (optionsProperty?.options ?? []) as Array<{
name: string;
builderHint?: { propertyHint?: string };
}>;
const fallbackOutput = optionParameters.find((option) => option.name === 'fallbackOutput');
const renameFallbackOutput = optionParameters.find(
(option) => option.name === 'renameFallbackOutput',
);
expect(sdkExample).toContain("fallbackOutput: 'extra'");
expect(sdkExample).toContain('.onCase(2, archive)');
expect(sdkExample).not.toContain('.onDefault(');
expect(sdkExample).not.toMatch(/\.onCase\(['"]/);
expect(fallbackOutput?.builderHint?.propertyHint).toContain('rules.values.length');
expect(fallbackOutput?.builderHint?.propertyHint).toContain('Numeric values');
expect(renameFallbackOutput?.builderHint?.propertyHint).toContain("fallbackOutput: 'extra'");
});
});
describe('Expression Mode Execution', () => {