/** * Canvas V2 Only * @TODO Remove this notice when Canvas V2 is the only one in use */ import { useI18n } from '@/composables/useI18n'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useWorkflowsStore } from '@/stores/workflows.store'; import type { Ref } from 'vue'; import { computed } from 'vue'; import type { BoundingBox, CanvasConnection, CanvasConnectionData, CanvasConnectionPort, CanvasNode, CanvasNodeAddNodesRender, CanvasNodeData, CanvasNodeDefaultRender, CanvasNodeDefaultRenderLabelSize, CanvasNodeStickyNoteRender, ExecutionOutputMap, } from '@/types'; import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types'; import { checkOverlap, mapLegacyConnectionsToCanvasConnections, mapLegacyEndpointsToCanvasConnectionPort, parseCanvasConnectionHandleString, } from '@/utils/canvasUtilsV2'; import type { ExecutionStatus, ExecutionSummary, IConnections, INodeExecutionData, ITaskData, Workflow, } from 'n8n-workflow'; import { NodeConnectionType, NodeHelpers } from 'n8n-workflow'; import type { INodeUi } from '@/Interface'; import { CUSTOM_API_CALL_KEY, STICKY_NODE_TYPE, WAIT_NODE_TYPE, WAIT_TIME_UNLIMITED, } from '@/constants'; import { sanitizeHtml } from '@/utils/htmlUtils'; import { MarkerType } from '@vue-flow/core'; import { useNodeHelpers } from './useNodeHelpers'; export function useCanvasMapping({ nodes, connections, workflowObject, }: { nodes: Ref; connections: Ref; workflowObject: Ref; }) { const i18n = useI18n(); const workflowsStore = useWorkflowsStore(); const nodeTypesStore = useNodeTypesStore(); const nodeHelpers = useNodeHelpers(); function createStickyNoteRenderType(node: INodeUi): CanvasNodeStickyNoteRender { return { type: CanvasNodeRenderType.StickyNote, options: { width: node.parameters.width as number, height: node.parameters.height as number, color: node.parameters.color as number, content: node.parameters.content as string, }, }; } function createAddNodesRenderType(): CanvasNodeAddNodesRender { return { type: CanvasNodeRenderType.AddNodes, options: {}, }; } function createDefaultNodeRenderType(node: INodeUi): CanvasNodeDefaultRender { return { type: CanvasNodeRenderType.Default, options: { trigger: nodeTypesStore.isTriggerNode(node.type), configuration: nodeTypesStore.isConfigNode(workflowObject.value, node, node.type), configurable: nodeTypesStore.isConfigurableNode(workflowObject.value, node, node.type), inputs: { labelSize: nodeInputLabelSizeById.value[node.id], }, outputs: { labelSize: nodeOutputLabelSizeById.value[node.id], }, }, }; } const renderTypeByNodeId = computed( () => nodes.value.reduce>((acc, node) => { switch (node.type) { case `${CanvasNodeRenderType.StickyNote}`: acc[node.id] = createStickyNoteRenderType(node); break; case `${CanvasNodeRenderType.AddNodes}`: acc[node.id] = createAddNodesRenderType(); break; default: acc[node.id] = createDefaultNodeRenderType(node); } return acc; }, {}) ?? {}, ); const nodeSubtitleById = computed(() => { return nodes.value.reduce>((acc, node) => { try { const nodeTypeDescription = nodeTypesStore.getNodeType(node.type, node.typeVersion); if (!nodeTypeDescription) { return acc; } const nodeSubtitle = nodeHelpers.getNodeSubtitle(node, nodeTypeDescription, workflowObject.value) ?? ''; if (nodeSubtitle.includes(CUSTOM_API_CALL_KEY)) { return acc; } acc[node.id] = nodeSubtitle; } catch (e) {} return acc; }, {}); }); const nodeInputsById = computed(() => nodes.value.reduce>((acc, node) => { const nodeTypeDescription = nodeTypesStore.getNodeType(node.type, node.typeVersion); const workflowObjectNode = workflowObject.value.getNode(node.name); acc[node.id] = workflowObjectNode && nodeTypeDescription ? mapLegacyEndpointsToCanvasConnectionPort( NodeHelpers.getNodeInputs( workflowObject.value, workflowObjectNode, nodeTypeDescription, ), nodeTypeDescription.inputNames ?? [], ) : []; return acc; }, {}), ); function getLabelSize(label: string = ''): number { if (label.length <= 2) { return 0; } else if (label.length <= 6) { return 1; } else { return 2; } } function getMaxNodePortsLabelSize( ports: CanvasConnectionPort[], ): CanvasNodeDefaultRenderLabelSize { const labelSizes: CanvasNodeDefaultRenderLabelSize[] = ['small', 'medium', 'large']; const labelSizeIndexes = ports.reduce( (sizeAcc, input) => { if (input.type === NodeConnectionType.Main) { sizeAcc.push(getLabelSize(input.label ?? '')); } return sizeAcc; }, [0], ); return labelSizes[Math.max(...labelSizeIndexes)]; } const nodeInputLabelSizeById = computed(() => nodes.value.reduce>((acc, node) => { acc[node.id] = getMaxNodePortsLabelSize(nodeInputsById.value[node.id]); return acc; }, {}), ); const nodeOutputLabelSizeById = computed(() => nodes.value.reduce>((acc, node) => { acc[node.id] = getMaxNodePortsLabelSize(nodeOutputsById.value[node.id]); return acc; }, {}), ); const nodeOutputsById = computed(() => nodes.value.reduce>((acc, node) => { const nodeTypeDescription = nodeTypesStore.getNodeType(node.type, node.typeVersion); const workflowObjectNode = workflowObject.value.getNode(node.name); acc[node.id] = workflowObjectNode && nodeTypeDescription ? mapLegacyEndpointsToCanvasConnectionPort( NodeHelpers.getNodeOutputs( workflowObject.value, workflowObjectNode, nodeTypeDescription, ), nodeTypeDescription.outputNames ?? [], ) : []; return acc; }, {}), ); const nodePinnedDataById = computed(() => nodes.value.reduce>((acc, node) => { acc[node.id] = workflowsStore.pinDataByNodeName(node.name); return acc; }, {}), ); const nodeExecutionRunningById = computed(() => nodes.value.reduce>((acc, node) => { acc[node.id] = workflowsStore.isNodeExecuting(node.name); return acc; }, {}), ); const nodeExecutionStatusById = computed(() => nodes.value.reduce>((acc, node) => { acc[node.id] = workflowsStore.getWorkflowRunData?.[node.name]?.filter(Boolean)[0]?.executionStatus ?? 'new'; return acc; }, {}), ); const nodeExecutionRunDataById = computed(() => nodes.value.reduce>((acc, node) => { acc[node.id] = workflowsStore.getWorkflowResultDataByNodeName(node.name); return acc; }, {}), ); const nodeExecutionRunDataOutputMapById = computed(() => Object.keys(nodeExecutionRunDataById.value).reduce>( (acc, nodeId) => { acc[nodeId] = {}; const outputData = { iterations: 0, total: 0 }; for (const runIteration of nodeExecutionRunDataById.value[nodeId] ?? []) { const data = runIteration.data ?? {}; for (const connectionType of Object.keys(data)) { const connectionTypeData = data[connectionType] ?? {}; acc[nodeId][connectionType] = acc[nodeId][connectionType] ?? {}; for (const outputIndex of Object.keys(connectionTypeData)) { const parsedOutputIndex = parseInt(outputIndex, 10); const connectionTypeOutputIndexData = connectionTypeData[parsedOutputIndex] ?? []; acc[nodeId][connectionType][outputIndex] = acc[nodeId][connectionType][ outputIndex ] ?? { ...outputData }; acc[nodeId][connectionType][outputIndex].iterations += 1; acc[nodeId][connectionType][outputIndex].total += connectionTypeOutputIndexData.length; } } } return acc; }, {}, ), ); const nodeIssuesById = computed(() => nodes.value.reduce>((acc, node) => { const issues: string[] = []; const nodeExecutionRunData = workflowsStore.getWorkflowRunData?.[node.name]; if (nodeExecutionRunData) { nodeExecutionRunData.forEach((executionRunData) => { if (executionRunData?.error) { const { message, description } = executionRunData.error; const issue = `${message}${description ? ` (${description})` : ''}`; issues.push(sanitizeHtml(issue)); } }); } if (node?.issues !== undefined) { issues.push(...NodeHelpers.nodeIssuesToString(node.issues, node)); } acc[node.id] = issues; return acc; }, {}), ); const nodeHasIssuesById = computed(() => nodes.value.reduce>((acc, node) => { if (['crashed', 'error'].includes(nodeExecutionStatusById.value[node.id])) { acc[node.id] = true; } else if (nodePinnedDataById.value[node.id]) { acc[node.id] = false; } else { acc[node.id] = Object.keys(node?.issues ?? {}).length > 0; } return acc; }, {}), ); const nodeExecutionWaitingById = computed(() => nodes.value.reduce>((acc, node) => { const isExecutionSummary = (execution: object): execution is ExecutionSummary => 'waitTill' in execution; const workflowExecution = workflowsStore.getWorkflowExecution; const lastNodeExecuted = workflowExecution?.data?.resultData?.lastNodeExecuted; if (workflowExecution && lastNodeExecuted && isExecutionSummary(workflowExecution)) { if ( node.name === workflowExecution.data?.resultData?.lastNodeExecuted && workflowExecution?.waitTill && !workflowExecution?.finished ) { if ( node && node.type === WAIT_NODE_TYPE && ['webhook', 'form'].includes(node.parameters.resume as string) ) { acc[node.id] = node.parameters.resume === 'webhook' ? i18n.baseText('node.theNodeIsWaitingWebhookCall') : i18n.baseText('node.theNodeIsWaitingFormCall'); return acc; } const waitDate = new Date(workflowExecution.waitTill); if (waitDate.toISOString() === WAIT_TIME_UNLIMITED) { acc[node.id] = i18n.baseText( 'node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall', ); } acc[node.id] = i18n.baseText('node.nodeIsWaitingTill', { interpolate: { date: waitDate.toLocaleDateString(), time: waitDate.toLocaleTimeString(), }, }); } } return acc; }, {}), ); const additionalNodePropertiesById = computed(() => { type StickyNoteBoundingBox = BoundingBox & { id: string; area: number; zIndex: number; }; const stickyNodeBaseZIndex = -100; const stickyNodeBoundingBoxes = nodes.value.reduce((acc, node) => { if (node.type === STICKY_NODE_TYPE) { const x = node.position[0]; const y = node.position[1]; const width = node.parameters.width as number; const height = node.parameters.height as number; acc.push({ id: node.id, x, y, width, height, area: width * height, zIndex: stickyNodeBaseZIndex, }); } return acc; }, []); const sortedStickyNodeBoundingBoxes = stickyNodeBoundingBoxes.sort((a, b) => b.area - a.area); sortedStickyNodeBoundingBoxes.forEach((node, index) => { node.zIndex = stickyNodeBaseZIndex + index; }); for (let i = 0; i < sortedStickyNodeBoundingBoxes.length; i++) { const node1 = sortedStickyNodeBoundingBoxes[i]; for (let j = i + 1; j < sortedStickyNodeBoundingBoxes.length; j++) { const node2 = sortedStickyNodeBoundingBoxes[j]; if (checkOverlap(node1, node2)) { if (node1.area < node2.area && node1.zIndex <= node2.zIndex) { // Ensure node1 (smaller area) has a higher zIndex than node2 (larger area) node1.zIndex = node2.zIndex + 1; } else if (node2.area < node1.area && node2.zIndex <= node1.zIndex) { // Ensure node2 (smaller area) has a higher zIndex than node1 (larger area) node2.zIndex = node1.zIndex + 1; } } } } return sortedStickyNodeBoundingBoxes.reduce>>( (acc, node) => { acc[node.id] = { style: { zIndex: node.zIndex, }, }; return acc; }, {}, ); }); const mappedNodes = computed(() => [ ...nodes.value.map((node) => { const inputConnections = workflowObject.value.connectionsByDestinationNode[node.name] ?? {}; const outputConnections = workflowObject.value.connectionsBySourceNode[node.name] ?? {}; const data: CanvasNodeData = { id: node.id, name: node.name, subtitle: nodeSubtitleById.value[node.id] ?? '', type: node.type, typeVersion: node.typeVersion, disabled: node.disabled, inputs: nodeInputsById.value[node.id] ?? [], outputs: nodeOutputsById.value[node.id] ?? [], connections: { [CanvasConnectionMode.Input]: inputConnections, [CanvasConnectionMode.Output]: outputConnections, }, issues: { items: nodeIssuesById.value[node.id], visible: nodeHasIssuesById.value[node.id], }, pinnedData: { count: nodePinnedDataById.value[node.id]?.length ?? 0, visible: !!nodePinnedDataById.value[node.id], }, execution: { status: nodeExecutionStatusById.value[node.id], waiting: nodeExecutionWaitingById.value[node.id], running: nodeExecutionRunningById.value[node.id], }, runData: { outputMap: nodeExecutionRunDataOutputMapById.value[node.id], iterations: nodeExecutionRunDataById.value[node.id]?.length ?? 0, visible: !!nodeExecutionRunDataById.value[node.id], }, render: renderTypeByNodeId.value[node.id] ?? { type: 'default', options: {} }, }; return { id: node.id, label: node.name, type: 'canvas-node', position: { x: node.position[0], y: node.position[1] }, data, ...additionalNodePropertiesById.value[node.id], }; }), ]); const mappedConnections = computed(() => { return mapLegacyConnectionsToCanvasConnections(connections.value ?? [], nodes.value ?? []).map( (connection) => { const type = getConnectionType(connection); const label = getConnectionLabel(connection); const data = getConnectionData(connection); return { ...connection, data, type, label, animated: data.status === 'running', markerEnd: MarkerType.ArrowClosed, }; }, ); }); function getConnectionData(connection: CanvasConnection): CanvasConnectionData { const fromNode = nodes.value.find((node) => node.name === connection.data?.fromNodeName); let status: CanvasConnectionData['status']; if (fromNode) { const { type, index } = parseCanvasConnectionHandleString(connection.sourceHandle); const runDataTotal = nodeExecutionRunDataOutputMapById.value[fromNode.id]?.[type]?.[index]?.total ?? 0; if (nodeExecutionRunningById.value[fromNode.id]) { status = 'running'; } else if ( nodePinnedDataById.value[fromNode.id] && nodeExecutionRunDataById.value[fromNode.id] ) { status = 'pinned'; } else if (nodeHasIssuesById.value[fromNode.id]) { status = 'error'; } else if (runDataTotal > 0) { status = 'success'; } } return { ...(connection.data as CanvasConnectionData), status, }; } function getConnectionType(_: CanvasConnection): string { return 'canvas-edge'; } function getConnectionLabel(connection: CanvasConnection): string { const fromNode = nodes.value.find((node) => node.name === connection.data?.fromNodeName); if (!fromNode) { return ''; } if (nodePinnedDataById.value[fromNode.id]) { const pinnedDataCount = nodePinnedDataById.value[fromNode.id]?.length ?? 0; return i18n.baseText('ndv.output.items', { adjustToNumber: pinnedDataCount, interpolate: { count: String(pinnedDataCount) }, }); } else if (nodeExecutionRunDataById.value[fromNode.id]) { const { type, index } = parseCanvasConnectionHandleString(connection.sourceHandle); const runDataTotal = nodeExecutionRunDataOutputMapById.value[fromNode.id]?.[type]?.[index]?.total ?? 0; return i18n.baseText('ndv.output.items', { adjustToNumber: runDataTotal, interpolate: { count: String(runDataTotal) }, }); } return ''; } return { additionalNodePropertiesById, nodeExecutionRunDataOutputMapById, connections: mappedConnections, nodes: mappedNodes, }; }