fix(editor): Fix inputs when extracting sub-workflows with Split Out nodes (#19923)

This commit is contained in:
Jaakko Husso 2025-10-09 09:41:22 +03:00 committed by GitHub
parent 65b1df9210
commit fa64bf1ef3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 113 additions and 21 deletions

View File

@ -218,14 +218,14 @@ export function useWorkflowExtraction() {
]
: [];
const triggerParameters =
selectionVariables.size > 0
selectionVariables.size === 0
? {
inputSource: 'passthrough',
}
: {
workflowInputs: {
values: [...selectionVariables.keys().map((k) => ({ name: k, type: 'any' }))],
},
}
: {
inputSource: 'passthrough',
};
const triggerNode: INode = {
@ -505,6 +505,20 @@ export function useWorkflowExtraction() {
return true;
}
function trackStartExtractWorkflow(nodeCount: number, success: boolean) {
telemetry.track('User started nodes to sub-workflow extraction', {
node_count: nodeCount,
success,
});
}
function trackExtractWorkflow(nodeCount: number, success: boolean) {
telemetry.track('User extracted nodes to sub-workflow', {
node_count: nodeCount,
success,
});
}
/**
* This mutates the current workflow and creates a new one.
* Intended to be called from @WorkflowExtractionNameModal spawned
@ -526,25 +540,11 @@ export function useWorkflowExtraction() {
*
* @param nodeIds the ids to be extracted from the current workflow into a sub-workflow
*/
async function extractWorkflow(nodeIds: string[]) {
function extractWorkflow(nodeIds: string[]) {
const success = tryExtractNodesIntoSubworkflow(nodeIds);
trackStartExtractWorkflow(nodeIds.length, success);
}
function trackStartExtractWorkflow(nodeCount: number, success: boolean) {
telemetry.track('User started nodes to sub-workflow extraction', {
node_count: nodeCount,
success,
});
}
function trackExtractWorkflow(nodeCount: number, success: boolean) {
telemetry.track('User extracted nodes to sub-workflow', {
node_count: nodeCount,
success,
});
}
return {
adjacencyList,
extractWorkflow,

View File

@ -592,7 +592,7 @@ function onTidyUp(event: CanvasLayoutEvent, options?: { trackEvents?: boolean })
}
function onExtractWorkflow(nodeIds: string[]) {
void extractWorkflow(nodeIds);
extractWorkflow(nodeIds);
}
function onUpdateNodesPosition(events: CanvasNodeMoveEvent[]) {

View File

@ -46,6 +46,8 @@ const ITEM_TO_DATA_ACCESSORS = [
/^item/,
];
const SPLIT_OUT_NODE_TYPE = 'n8n-nodes-base.splitOut';
// These we safely can convert to a normal argument
const ITEM_ACCESSORS = ['params', 'isExecuted'];
@ -492,6 +494,7 @@ export function extractReferencesInNodeExpressions(
insertedStartName: string,
graphInputNodeNames?: string[],
) {
const [start] = graphInputNodeNames ?? [];
////
// STEP 1 - Validate input invariants
////
@ -532,6 +535,8 @@ export function extractReferencesInNodeExpressions(
const parameterTreeMappingByNode = new Map<string, ParameterExtractMapping>();
// This is used to track all candidates for change, necessary for deduplication
const allData = [];
// Additional mappings that should contribute to sub-workflow inputs (e.g. Split Out 'fieldToSplitOut')
const extraVariableCandidates: ExpressionMapping[] = [];
for (const node of subGraph) {
const [parameterMapping, allMappings] = applyParameterMapping(node.parameters, (s) =>
@ -545,6 +550,40 @@ export function extractReferencesInNodeExpressions(
);
parameterTreeMappingByNode.set(node.name, parameterMapping);
allData.push(...allMappings);
if (node.name === start && node.type === SPLIT_OUT_NODE_TYPE) {
const raw = node.parameters?.fieldToSplitOut;
if (typeof raw === 'string' && raw.trim() !== '') {
const trimmed = raw.trim();
const isExpression = trimmed.startsWith('=');
// Expressions in Split Out 'fieldToSplitOut' parameters are not supported,
// as they define the fields to split out only at execution time.
if (isExpression) {
throw new OperationalError(
`Extracting sub-workflow from Split Out node with 'fieldToSplitOut' parameter having expression "${trimmed}" is not supported.`,
);
}
// Parameter value is a CSV of fields to split out.
// Create synthetic $json expressions for each field
const fields = isExpression
? [trimmed]
: trimmed.split(',').map((field) => `={{$json.${field.trim()}}}`);
for (const expression of fields) {
const mappingsFromField = parseReferencingExpressions(
expression,
nodeRegexps,
nodeNames,
insertedStartName,
graphInputNodeNames?.includes(node.name) ?? false,
);
extraVariableCandidates.push(...mappingsFromField);
}
}
}
}
////
@ -552,7 +591,7 @@ export function extractReferencesInNodeExpressions(
////
const subGraphNodeNames = new Set(subGraphNames);
const dataFromOutsideSubgraph = allData.filter(
const dataFromOutsideSubgraph = [...allData, ...extraVariableCandidates].filter(
// `nodeNameInExpression` being absent implies direct access via `$json` or `$binary`
(x) => !x.nodeNameInExpression || !subGraphNodeNames.has(x.nodeNameInExpression),
);
@ -588,6 +627,17 @@ export function extractReferencesInNodeExpressions(
output.push(result);
}
for (const candidate of extraVariableCandidates) {
const key = originalExpressionMap.get(candidate.originalExpression);
if (!key) continue;
const canonical = triggerArgumentMap.get(key);
if (!canonical) continue;
if (!allUsedMappings.some((u) => u.replacementName === canonical.replacementName)) {
allUsedMappings.push(canonical);
}
}
const variables = new Map(allUsedMappings.map((m) => [m.replacementName, m.originalExpression]));
return { nodes: output, variables };
}

View File

@ -742,5 +742,47 @@ describe('NodeReferenceParserUtils', () => {
},
]);
});
it('should extract "fieldToSplitOut" constant fields in n8n-nodes-base.splitOut', () => {
nodes = [
{
parameters: {
fieldToSplitOut: 'foo,bar',
},
type: 'n8n-nodes-base.splitOut',
typeVersion: 1,
position: [200, 200],
id: 'splitOutNodeId',
name: 'A',
},
];
nodeNames = ['A', 'B'];
const result = extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, ['A']);
expect([...result.variables.entries()]).toEqual([
['foo', '$json.foo'],
['bar', '$json.bar'],
]);
});
it('should error at extracting "fieldToSplitOut" expression in n8n-nodes-base.splitOut', () => {
nodes = [
{
parameters: {
fieldToSplitOut: '={{ foo,bar }}',
},
type: 'n8n-nodes-base.splitOut',
typeVersion: 1,
position: [200, 200],
id: 'splitOutNodeId',
name: 'A',
},
];
nodeNames = ['A', 'B'];
expect(() =>
extractReferencesInNodeExpressions(nodes, nodeNames, startNodeName, ['A']),
).toThrow('not supported');
});
});
});