diff --git a/packages/frontend/editor-ui/src/composables/useWorkflowExtraction.ts b/packages/frontend/editor-ui/src/composables/useWorkflowExtraction.ts index 184b7cc59ca..530b30731a0 100644 --- a/packages/frontend/editor-ui/src/composables/useWorkflowExtraction.ts +++ b/packages/frontend/editor-ui/src/composables/useWorkflowExtraction.ts @@ -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, diff --git a/packages/frontend/editor-ui/src/views/NodeView.vue b/packages/frontend/editor-ui/src/views/NodeView.vue index 0e77522a2e0..079b083e992 100644 --- a/packages/frontend/editor-ui/src/views/NodeView.vue +++ b/packages/frontend/editor-ui/src/views/NodeView.vue @@ -592,7 +592,7 @@ function onTidyUp(event: CanvasLayoutEvent, options?: { trackEvents?: boolean }) } function onExtractWorkflow(nodeIds: string[]) { - void extractWorkflow(nodeIds); + extractWorkflow(nodeIds); } function onUpdateNodesPosition(events: CanvasNodeMoveEvent[]) { diff --git a/packages/workflow/src/node-reference-parser-utils.ts b/packages/workflow/src/node-reference-parser-utils.ts index 800d20599af..28b9895ffbb 100644 --- a/packages/workflow/src/node-reference-parser-utils.ts +++ b/packages/workflow/src/node-reference-parser-utils.ts @@ -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(); // 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 }; } diff --git a/packages/workflow/test/node-reference-parser-utils.test.ts b/packages/workflow/test/node-reference-parser-utils.test.ts index 1350baea301..537abefb34f 100644 --- a/packages/workflow/test/node-reference-parser-utils.test.ts +++ b/packages/workflow/test/node-reference-parser-utils.test.ts @@ -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'); + }); }); });