mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
555 lines
15 KiB
TypeScript
555 lines
15 KiB
TypeScript
import { useWorkflowsStore } from '@/stores/workflows.store';
|
|
import {
|
|
buildAdjacencyList,
|
|
parseExtractableSubgraphSelection,
|
|
extractReferencesInNodeExpressions,
|
|
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
|
NodeHelpers,
|
|
} from 'n8n-workflow';
|
|
import type {
|
|
ExtractableSubgraphData,
|
|
ExtractableErrorResult,
|
|
IConnections,
|
|
INode,
|
|
Workflow,
|
|
} from 'n8n-workflow';
|
|
import { computed } from 'vue';
|
|
import { useToast } from './useToast';
|
|
import { useRouter } from 'vue-router';
|
|
import { VIEWS, WORKFLOW_EXTRACTION_NAME_MODAL_KEY } from '@/constants';
|
|
import { useHistoryStore } from '@/stores/history.store';
|
|
import { useCanvasOperations } from './useCanvasOperations';
|
|
|
|
import type { AddedNode, INodeUi, IWorkflowDb } from '@/Interface';
|
|
import type { WorkflowDataCreate } from '@n8n/rest-api-client/api/workflows';
|
|
import { useI18n } from '@n8n/i18n';
|
|
import { PUSH_NODES_OFFSET } from '@/utils/nodeViewUtils';
|
|
import { useUIStore } from '@/stores/ui.store';
|
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
|
import { useTelemetry } from './useTelemetry';
|
|
import isEqual from 'lodash/isEqual';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
|
|
const CANVAS_HISTORY_OPTIONS = {
|
|
trackBulk: false,
|
|
trackHistory: true,
|
|
};
|
|
|
|
export function useWorkflowExtraction() {
|
|
const uiStore = useUIStore();
|
|
const workflowsStore = useWorkflowsStore();
|
|
const toast = useToast();
|
|
const router = useRouter();
|
|
const historyStore = useHistoryStore();
|
|
const canvasOperations = useCanvasOperations();
|
|
const i18n = useI18n();
|
|
const telemetry = useTelemetry();
|
|
|
|
const adjacencyList = computed(() => buildAdjacencyList(workflowsStore.workflow.connections));
|
|
|
|
const workflowObject = computed(() => workflowsStore.workflowObject as Workflow);
|
|
|
|
function showError(message: string) {
|
|
toast.showMessage({
|
|
type: 'error',
|
|
message,
|
|
title: i18n.baseText('workflowExtraction.error.failure'),
|
|
duration: 15 * 1000,
|
|
});
|
|
}
|
|
|
|
function extractableErrorResultToMessage(result: ExtractableErrorResult) {
|
|
switch (result.errorCode) {
|
|
case 'Input Edge To Non-Root Node':
|
|
return i18n.baseText('workflowExtraction.error.selectionGraph.inputEdgeToNonRoot', {
|
|
interpolate: { node: result.node },
|
|
});
|
|
case 'Output Edge From Non-Leaf Node':
|
|
return i18n.baseText('workflowExtraction.error.selectionGraph.outputEdgeFromNonLeaf', {
|
|
interpolate: { node: result.node },
|
|
});
|
|
case 'Multiple Input Nodes':
|
|
return i18n.baseText('workflowExtraction.error.selectionGraph.multipleInputNodes', {
|
|
interpolate: { nodes: [...result.nodes].map((x) => `'${x}'`).join(', ') },
|
|
});
|
|
case 'Multiple Output Nodes':
|
|
return i18n.baseText('workflowExtraction.error.selectionGraph.multipleOutputNodes', {
|
|
interpolate: { nodes: [...result.nodes].map((x) => `'${x}'`).join(', ') },
|
|
});
|
|
case 'No Continuous Path From Root To Leaf In Selection':
|
|
return i18n.baseText(
|
|
'workflowExtraction.error.selectionGraph.noContinuousPathFromRootToLeaf',
|
|
{ interpolate: { start: result.start, end: result.end } },
|
|
);
|
|
}
|
|
}
|
|
|
|
function makeExecuteWorkflowNode(
|
|
workflowId: string,
|
|
name: string,
|
|
position: [number, number],
|
|
variables: Map<string, string>,
|
|
): Omit<INode, 'id'> {
|
|
return {
|
|
parameters: {
|
|
workflowId: {
|
|
__rl: true,
|
|
value: workflowId,
|
|
mode: 'list',
|
|
},
|
|
workflowInputs: {
|
|
mappingMode: 'defineBelow',
|
|
value: Object.fromEntries(variables.entries().map(([k, v]) => [k, `={{ ${v} }}`])),
|
|
matchingColumns: [...variables.keys()],
|
|
schema: [
|
|
...variables.keys().map((x) => ({
|
|
id: x,
|
|
displayName: x,
|
|
required: false,
|
|
defaultMatch: false,
|
|
display: true,
|
|
canBeUsedToMatch: true,
|
|
removed: false,
|
|
// omitted type implicitly uses our `any` type
|
|
})),
|
|
],
|
|
attemptToConvertTypes: false,
|
|
convertFieldsToString: true,
|
|
},
|
|
options: {},
|
|
},
|
|
type: 'n8n-nodes-base.executeWorkflow',
|
|
typeVersion: 1.2,
|
|
position,
|
|
name,
|
|
};
|
|
}
|
|
|
|
function makeSubworkflow(
|
|
newWorkflowName: string,
|
|
{ start, end }: ExtractableSubgraphData,
|
|
nodes: INodeUi[],
|
|
connections: IConnections,
|
|
selectionVariables: Map<string, string>,
|
|
selectionChildrenVariables: Map<string, string>,
|
|
startNodeName: string,
|
|
returnNodeName: string,
|
|
): WorkflowDataCreate {
|
|
const newConnections = Object.fromEntries(
|
|
Object.entries(connections).filter(([k]) => nodes.some((x) => x.name === k)),
|
|
);
|
|
if (end) {
|
|
// this is necessary because the new workflow may crash looking for the
|
|
// nodes in these connections
|
|
delete newConnections[end];
|
|
}
|
|
|
|
const startNodeTarget = nodes.find((x) => x.name === start);
|
|
const firstNode = startNodeTarget ?? nodes.sort((a, b) => a.position[1] - b.position[1])[0];
|
|
const startNodePosition: [number, number] = [
|
|
firstNode.position[0] - PUSH_NODES_OFFSET,
|
|
firstNode.position[1],
|
|
];
|
|
|
|
const endNodeTarget = nodes.find((x) => x.name === end);
|
|
const lastNode = endNodeTarget ?? nodes.sort((a, b) => b.position[1] - a.position[1])[0];
|
|
const endNodePosition: [number, number] = [
|
|
lastNode.position[0] + PUSH_NODES_OFFSET,
|
|
lastNode.position[1],
|
|
];
|
|
|
|
const shouldInsertReturnNode = selectionChildrenVariables.size > 0;
|
|
|
|
const startNodeConnection = startNodeTarget
|
|
? ({
|
|
[startNodeName]: {
|
|
main: [
|
|
[
|
|
{
|
|
node: startNodeTarget.name,
|
|
type: 'main',
|
|
index: 0,
|
|
},
|
|
],
|
|
],
|
|
},
|
|
} satisfies IConnections)
|
|
: {};
|
|
|
|
const endNodeConnection =
|
|
endNodeTarget && shouldInsertReturnNode
|
|
? ({
|
|
[endNodeTarget.name]: {
|
|
main: [
|
|
[
|
|
{
|
|
node: returnNodeName,
|
|
type: 'main',
|
|
index: 0,
|
|
},
|
|
],
|
|
],
|
|
},
|
|
} satisfies IConnections)
|
|
: {};
|
|
|
|
const returnNode = shouldInsertReturnNode
|
|
? [
|
|
{
|
|
parameters: {
|
|
assignments: {
|
|
assignments: [
|
|
...selectionChildrenVariables.entries().map((x) => ({
|
|
id: uuidv4(),
|
|
name: x[0],
|
|
value: `={{ ${x[1]} }}`,
|
|
type: 'string',
|
|
})),
|
|
],
|
|
},
|
|
options: {},
|
|
},
|
|
type: 'n8n-nodes-base.set',
|
|
typeVersion: 3.4,
|
|
position: endNodePosition,
|
|
id: uuidv4(),
|
|
name: returnNodeName,
|
|
} satisfies INode,
|
|
]
|
|
: [];
|
|
const triggerParameters =
|
|
selectionVariables.size > 0
|
|
? {
|
|
workflowInputs: {
|
|
values: [...selectionVariables.keys().map((k) => ({ name: k, type: 'any' }))],
|
|
},
|
|
}
|
|
: {
|
|
inputSource: 'passthrough',
|
|
};
|
|
|
|
const triggerNode: INode = {
|
|
id: uuidv4(),
|
|
typeVersion: 1.1,
|
|
name: startNodeName,
|
|
type: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
|
position: startNodePosition,
|
|
parameters: triggerParameters,
|
|
};
|
|
|
|
return {
|
|
name: newWorkflowName,
|
|
nodes: [...nodes, ...returnNode, triggerNode],
|
|
connections: {
|
|
...newConnections,
|
|
...startNodeConnection,
|
|
...endNodeConnection,
|
|
},
|
|
settings: { executionOrder: 'v1' },
|
|
projectId: workflowsStore.workflow.homeProject?.id,
|
|
parentFolderId: workflowsStore.workflow.parentFolder?.id ?? undefined,
|
|
};
|
|
}
|
|
|
|
function computeAveragePosition(nodes: INode[]): [number, number] {
|
|
const summedUp = nodes.reduce(
|
|
(acc, v) => [acc[0] + v.position[0], acc[1] + v.position[1], acc[2] + 1],
|
|
[0, 0, 0],
|
|
);
|
|
return [summedUp[0] / summedUp[2], summedUp[1] / summedUp[2]];
|
|
}
|
|
|
|
async function tryCreateWorkflow(workflowData: WorkflowDataCreate): Promise<IWorkflowDb | null> {
|
|
try {
|
|
const createdWorkflow = await workflowsStore.createNewWorkflow(workflowData);
|
|
|
|
const { href } = router.resolve({
|
|
name: VIEWS.WORKFLOW,
|
|
params: {
|
|
name: createdWorkflow.id,
|
|
},
|
|
});
|
|
|
|
toast.showMessage({
|
|
title: i18n.baseText('workflowExtraction.success.title'),
|
|
message: i18n.baseText('workflowExtraction.success.message', {
|
|
interpolate: { url: href },
|
|
}),
|
|
type: 'success',
|
|
duration: 10 * 1000,
|
|
});
|
|
return createdWorkflow;
|
|
} catch (e) {
|
|
toast.showError(e, i18n.baseText('workflowExtraction.error.subworkflowCreationFailed'));
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function checkExtractableSelectionValidity(
|
|
selection: ReturnType<typeof parseExtractableSubgraphSelection>,
|
|
): selection is ExtractableSubgraphData {
|
|
if (Array.isArray(selection)) {
|
|
showError(
|
|
i18n.baseText('workflowExtraction.error.selectionGraph.listHeader', {
|
|
interpolate: {
|
|
body: selection
|
|
.map(extractableErrorResultToMessage)
|
|
.map((x) => `- ${x}`)
|
|
.join('<br>'),
|
|
},
|
|
}),
|
|
);
|
|
return false;
|
|
}
|
|
const { start, end } = selection;
|
|
|
|
const isSingleIO = (
|
|
nodeName: string,
|
|
getIOs: (
|
|
...x: Parameters<typeof NodeHelpers.getNodeInputs>
|
|
) => ReturnType<typeof NodeHelpers.getNodeInputs>,
|
|
) => {
|
|
const node = workflowsStore.getNodeByName(nodeName);
|
|
if (!node) return true; // invariant broken -> abort onto error path
|
|
const nodeType = useNodeTypesStore().getNodeType(node.type, node.typeVersion);
|
|
if (!nodeType) return true; // invariant broken -> abort onto error path
|
|
|
|
const ios = getIOs(workflowObject.value, node, nodeType);
|
|
return (
|
|
ios.filter((x) => (typeof x === 'string' ? x === 'main' : x.type === 'main')).length <= 1
|
|
);
|
|
};
|
|
|
|
if (start && !isSingleIO(start, NodeHelpers.getNodeInputs)) {
|
|
showError(
|
|
i18n.baseText('workflowExtraction.error.inputNodeHasMultipleInputBranches', {
|
|
interpolate: { node: start },
|
|
}),
|
|
);
|
|
return false;
|
|
}
|
|
if (end && !isSingleIO(end, NodeHelpers.getNodeOutputs)) {
|
|
showError(
|
|
i18n.baseText('workflowExtraction.error.outputNodeHasMultipleOutputBranches', {
|
|
interpolate: { node: end },
|
|
}),
|
|
);
|
|
return false;
|
|
}
|
|
|
|
// Returns an array of errors
|
|
return !Array.isArray(selection);
|
|
}
|
|
|
|
async function replaceSelectionWithNode(
|
|
executeWorkflowNodeData: AddedNode,
|
|
startId: string | undefined,
|
|
endId: string | undefined,
|
|
selection: INode[],
|
|
selectionChildNodes: INode[],
|
|
) {
|
|
historyStore.startRecordingUndo();
|
|
|
|
// In most cases we're about to move the selection anyway
|
|
// One remarkable edge case is when a single node is right-clicked on
|
|
// This allows extraction, but does not necessarily select the node
|
|
uiStore.resetLastInteractedWith();
|
|
|
|
const executeWorkflowNode = (
|
|
await canvasOperations.addNodes([executeWorkflowNodeData], {
|
|
...CANVAS_HISTORY_OPTIONS,
|
|
forcePosition: true,
|
|
})
|
|
)[0];
|
|
|
|
if (endId)
|
|
canvasOperations.replaceNodeConnections(endId, executeWorkflowNode.id, {
|
|
...CANVAS_HISTORY_OPTIONS,
|
|
replaceInputs: false,
|
|
});
|
|
|
|
if (startId)
|
|
canvasOperations.replaceNodeConnections(startId, executeWorkflowNode.id, {
|
|
...CANVAS_HISTORY_OPTIONS,
|
|
replaceOutputs: false,
|
|
});
|
|
|
|
canvasOperations.deleteNodes(
|
|
selection.map((x) => x.id),
|
|
CANVAS_HISTORY_OPTIONS,
|
|
);
|
|
|
|
for (const node of selectionChildNodes) {
|
|
const currentNode = workflowsStore.workflow.nodes.find((x) => x.id === node.id);
|
|
|
|
if (isEqual(node, currentNode)) continue;
|
|
|
|
canvasOperations.replaceNodeParameters(
|
|
node.id,
|
|
{ ...currentNode?.parameters },
|
|
{ ...node.parameters },
|
|
CANVAS_HISTORY_OPTIONS,
|
|
);
|
|
}
|
|
|
|
uiStore.stateIsDirty = true;
|
|
historyStore.stopRecordingUndo();
|
|
}
|
|
|
|
function tryExtractNodesIntoSubworkflow(nodeIds: string[]): boolean {
|
|
const subGraph = nodeIds.map(workflowsStore.getNodeById).filter((x) => x !== undefined);
|
|
|
|
const triggers = subGraph.filter((x) =>
|
|
useNodeTypesStore().getNodeType(x.type, x.typeVersion)?.group.includes('trigger'),
|
|
);
|
|
if (triggers.length > 0) {
|
|
showError(
|
|
i18n.baseText('workflowExtraction.error.triggerSelected', {
|
|
interpolate: { nodes: triggers.map((x) => `'${x.name}'`).join(', ') },
|
|
}),
|
|
);
|
|
return false;
|
|
}
|
|
|
|
const selection = parseExtractableSubgraphSelection(
|
|
new Set(subGraph.map((x) => x.name)),
|
|
adjacencyList.value,
|
|
);
|
|
|
|
if (!checkExtractableSelectionValidity(selection)) return false;
|
|
|
|
uiStore.openModalWithData({
|
|
name: WORKFLOW_EXTRACTION_NAME_MODAL_KEY,
|
|
data: { subGraph, selection },
|
|
});
|
|
return true;
|
|
}
|
|
|
|
async function doExtractNodesIntoSubworkflow(
|
|
selection: ExtractableSubgraphData,
|
|
subGraph: INodeUi[],
|
|
newWorkflowName: string,
|
|
) {
|
|
const { start, end } = selection;
|
|
|
|
const allNodeNames = workflowsStore.workflow.nodes.map((x) => x.name);
|
|
|
|
let startNodeName = 'Start';
|
|
const subGraphNames = subGraph.map((x) => x.name);
|
|
while (subGraphNames.includes(startNodeName)) startNodeName += '_1';
|
|
|
|
let returnNodeName = 'Return';
|
|
while (subGraphNames.includes(returnNodeName)) returnNodeName += '_1';
|
|
|
|
const directAfterEndNodeNames = end
|
|
? workflowObject.value
|
|
.getChildNodes(end, 'main', 1)
|
|
.map((x) => workflowObject.value.getNode(x)?.name)
|
|
.filter((x) => x !== undefined)
|
|
: [];
|
|
|
|
const allAfterEndNodes = end
|
|
? workflowObject.value
|
|
.getChildNodes(end, 'ALL')
|
|
.map((x) => workflowObject.value.getNode(x))
|
|
.filter((x) => x !== null)
|
|
: [];
|
|
|
|
const { nodes, variables } = extractReferencesInNodeExpressions(
|
|
subGraph,
|
|
allNodeNames,
|
|
startNodeName,
|
|
start ? [start] : undefined,
|
|
);
|
|
|
|
let executeWorkflowNodeName = `Call ${newWorkflowName}`;
|
|
while (allNodeNames.includes(executeWorkflowNodeName)) executeWorkflowNodeName += '_1';
|
|
|
|
const { nodes: afterNodes, variables: afterVariables } = extractReferencesInNodeExpressions(
|
|
allAfterEndNodes,
|
|
allAfterEndNodes
|
|
.map((x) => x.name)
|
|
.concat(subGraphNames), // this excludes nodes that will remain in the parent workflow
|
|
executeWorkflowNodeName,
|
|
directAfterEndNodeNames,
|
|
);
|
|
|
|
const workflowData = makeSubworkflow(
|
|
newWorkflowName,
|
|
selection,
|
|
nodes,
|
|
workflowsStore.workflow.connections,
|
|
variables,
|
|
afterVariables,
|
|
startNodeName,
|
|
returnNodeName,
|
|
);
|
|
const createdWorkflow = await tryCreateWorkflow(workflowData);
|
|
if (createdWorkflow === null) return false;
|
|
|
|
const executeWorkflowPosition = computeAveragePosition(subGraph);
|
|
const executeWorkflowNode = makeExecuteWorkflowNode(
|
|
createdWorkflow.id,
|
|
executeWorkflowNodeName,
|
|
executeWorkflowPosition,
|
|
variables,
|
|
);
|
|
await replaceSelectionWithNode(
|
|
executeWorkflowNode,
|
|
subGraph.find((x) => x.name === start)?.id,
|
|
subGraph.find((x) => x.name === end)?.id,
|
|
subGraph,
|
|
afterNodes,
|
|
);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* This mutates the current workflow and creates a new one.
|
|
* Intended to be called from @WorkflowExtractionNameModal spawned
|
|
* by @tryExtractNodesIntoSubworkflow
|
|
*/
|
|
async function extractNodesIntoSubworkflow(
|
|
selection: ExtractableSubgraphData,
|
|
subGraph: INodeUi[],
|
|
newWorkflowName: string,
|
|
) {
|
|
const success = await doExtractNodesIntoSubworkflow(selection, subGraph, newWorkflowName);
|
|
trackExtractWorkflow(subGraph.length, success);
|
|
}
|
|
|
|
/**
|
|
* This starts the extraction process by checking whether the selection is extractable
|
|
* and spawning a pop up asking for a sub-workflow name.
|
|
* If confirmed, the modal calls @extractNodesIntoSubworkflow to handle the actual mutation
|
|
*
|
|
* @param nodeIds the ids to be extracted from the current workflow into a sub-workflow
|
|
*/
|
|
async 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,
|
|
tryExtractNodesIntoSubworkflow,
|
|
extractNodesIntoSubworkflow,
|
|
};
|
|
}
|