From fad3ee23f6025ac74d44fa01eb4473bb05dd8002 Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Thu, 11 Jun 2026 10:03:12 +0300 Subject: [PATCH] refactor(editor): Add per-node-id projections and remove workflowState store (no-changelog) (#31001) Co-authored-by: Claude Opus 4.7 (1M context) --- .../app/composables/useNodeDirtiness.test.ts | 57 +- .../src/app/composables/useNodeDirtiness.ts | 29 +- .../src/app/composables/useNodeHelpers.ts | 73 +- .../src/app/composables/useRunWorkflow.ts | 2 +- .../app/composables/useWorkflowUpdate.test.ts | 6 +- .../app/stores/executionData.store.test.ts | 457 +- .../src/app/stores/executionData.store.ts | 284 +- .../src/app/stores/workflowDocument.store.ts | 19 +- .../useWorkflowDocumentGraph.test.ts | 1 - .../useWorkflowDocumentNodes.test.ts | 30 +- .../useWorkflowDocumentNodes.ts | 3 - .../useWorkflowDocumentNodesIssues.test.ts | 5 +- .../useWorkflowDocumentNodesIssues.ts | 80 +- .../useWorkflowDocumentPinData.test.ts | 56 +- .../useWorkflowDocumentPinData.ts | 104 +- .../useWorkflowDocumentRenderData.test.ts | 501 +- .../useWorkflowDocumentRenderData.ts | 519 ++- .../workflowExecutionState.store.test.ts | 848 ++-- .../stores/workflowExecutionState.store.ts | 178 +- .../editor-ui/src/app/types/executionData.ts | 29 + .../frontend/editor-ui/src/app/types/index.ts | 1 + .../editor-ui/src/app/utils/nodeTypesUtils.ts | 69 + .../src/app/utils/nodes/nodeTransforms.ts | 14 + .../ndv/panel/components/OutputPanel.vue | 2 +- .../workflows/canvas/__tests__/utils.ts | 2 +- .../features/workflows/canvas/canvas.types.ts | 18 +- .../features/workflows/canvas/canvas.utils.ts | 43 +- .../canvas/components/WorkflowCanvas.vue | 41 +- .../composables/useCanvasLayout.test.ts | 12 +- .../composables/useCanvasMapping.test.ts | 4053 ++--------------- .../canvas/composables/useCanvasMapping.ts | 733 +-- .../workflowDiff/SyncedWorkflowCanvas.test.ts | 36 +- .../workflowDiff/WorkflowDiffContent.test.ts | 15 +- .../workflowDiff/useWorkflowDiff.test.ts | 45 +- .../workflows/workflowDiff/useWorkflowDiff.ts | 84 +- 35 files changed, 3423 insertions(+), 5026 deletions(-) create mode 100644 packages/frontend/editor-ui/src/app/types/executionData.ts diff --git a/packages/frontend/editor-ui/src/app/composables/useNodeDirtiness.test.ts b/packages/frontend/editor-ui/src/app/composables/useNodeDirtiness.test.ts index 3dda2f28107..415a1423f38 100644 --- a/packages/frontend/editor-ui/src/app/composables/useNodeDirtiness.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/useNodeDirtiness.test.ts @@ -25,6 +25,9 @@ import { import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store'; import { WorkflowDocumentStoreKey } from '@/app/constants/injectionKeys'; +const TEST_WORKFLOW_ID = 'test-workflow-id'; +const TEST_DOCUMENT_ID = createWorkflowDocumentId(TEST_WORKFLOW_ID); + describe(useNodeDirtiness, () => { let nodeTypeStore: ReturnType; let workflowsStore: ReturnType; @@ -39,8 +42,6 @@ describe(useNodeDirtiness, () => { beforeEach(() => { vi.useFakeTimers(); - const TEST_WORKFLOW_ID = 'test-workflow-id'; - const TestComponent = defineComponent({ setup() { nodeTypeStore = useNodeTypesStore(); @@ -48,9 +49,7 @@ describe(useNodeDirtiness, () => { workflowsStore.setWorkflowId(TEST_WORKFLOW_ID); historyHelper = useHistoryHelper({} as RouteLocationNormalizedLoaded); - workflowDocumentStore = useWorkflowDocumentStore( - createWorkflowDocumentId(TEST_WORKFLOW_ID), - ); + workflowDocumentStore = useWorkflowDocumentStore(TEST_DOCUMENT_ID); provide(WorkflowDocumentStoreKey, shallowRef(workflowDocumentStore)); canvasOperations = useCanvasOperations(); @@ -77,7 +76,7 @@ describe(useNodeDirtiness, () => { it('should be an empty object if no change has been made to the workflow', () => { setupTestWorkflow('aπŸš¨βœ…, bβœ…, cβœ…'); - expect(useNodeDirtiness().dirtinessByName.value).toEqual({}); + expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({}); }); describe('injecting a node', () => { @@ -95,7 +94,7 @@ describe(useNodeDirtiness, () => { await canvasOperations.addNodes([createTestNode({ name: 'c' })], { trackHistory: true }); - expect(useNodeDirtiness().dirtinessByName.value).toEqual({ + expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({ b: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED, }); }); @@ -111,7 +110,7 @@ describe(useNodeDirtiness, () => { trackHistory: true, }); // 'a' becomes new parent of 'c' - expect(useNodeDirtiness().dirtinessByName.value).toEqual({ + expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({ c: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED, }); }); @@ -125,7 +124,7 @@ describe(useNodeDirtiness, () => { trackHistory: true, }); // 'b' has no parent node anymore - expect(useNodeDirtiness().dirtinessByName.value).toEqual({ + expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({ b: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED, }); }); @@ -137,7 +136,7 @@ describe(useNodeDirtiness, () => { canvasOperations.setNodeParameters(workflowDocumentStore.nodesByName.b.id, { foo: 1 }); - expect(useNodeDirtiness().dirtinessByName.value).toEqual({ + expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({ b: CanvasNodeDirtiness.PARAMETERS_UPDATED, }); }); @@ -178,7 +177,7 @@ describe(useNodeDirtiness, () => { }), }); - expect(useNodeDirtiness().dirtinessByName.value).toEqual({}); + expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({}); }); it("should not update dirtiness if the node hasn't run yet", () => { @@ -186,7 +185,7 @@ describe(useNodeDirtiness, () => { canvasOperations.setNodeParameters(workflowDocumentStore.nodesByName.b.id, { foo: 1 }); - expect(useNodeDirtiness().dirtinessByName.value).toEqual({}); + expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({}); }); it('should not update dirtiness when the notes field is updated', () => { @@ -198,7 +197,7 @@ describe(useNodeDirtiness, () => { value: 'test', }); - expect(useNodeDirtiness().dirtinessByName.value).toEqual({}); + expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({}); }); }); @@ -216,7 +215,7 @@ describe(useNodeDirtiness, () => { { trackHistory: true }, ); - expect(useNodeDirtiness().dirtinessByName.value).toEqual({ + expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({ c: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED, }); }); @@ -236,7 +235,7 @@ describe(useNodeDirtiness, () => { { trackHistory: true }, ); - expect(useNodeDirtiness().dirtinessByName.value).toEqual({}); + expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({}); }); }); @@ -248,7 +247,7 @@ describe(useNodeDirtiness, () => { trackHistory: true, }); - expect(useNodeDirtiness().dirtinessByName.value).toEqual({ + expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({ c: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED, }); }); @@ -260,7 +259,7 @@ describe(useNodeDirtiness, () => { trackHistory: true, }); - expect(useNodeDirtiness().dirtinessByName.value).toEqual({}); + expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({}); }); it('should restore original dirtiness after undoing a command', async () => { @@ -270,13 +269,13 @@ describe(useNodeDirtiness, () => { trackHistory: true, }); - expect(useNodeDirtiness().dirtinessByName.value).toEqual({ + expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({ c: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED, }); await historyHelper.undo(); - expect(useNodeDirtiness().dirtinessByName.value).toEqual({}); + expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({}); }); }); @@ -292,7 +291,7 @@ describe(useNodeDirtiness, () => { }, ); - expect(useNodeDirtiness().dirtinessByName.value).toEqual({}); + expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({}); }); it('should update dirtiness when pinned data is removed from a node with run data', async () => { @@ -306,7 +305,7 @@ describe(useNodeDirtiness, () => { }, ); - expect(useNodeDirtiness().dirtinessByName.value).toEqual({ + expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({ b: CanvasNodeDirtiness.PINNED_DATA_UPDATED, }); }); @@ -320,7 +319,7 @@ describe(useNodeDirtiness, () => { ); workflowDocumentStore.touchPinnedDataLastUpdatedAt('b'); - expect(useNodeDirtiness().dirtinessByName.value).toEqual({ + expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({ // 'd' is not marked as pinned-data-updated because it has no run data. c: CanvasNodeDirtiness.PINNED_DATA_UPDATED, e: CanvasNodeDirtiness.PINNED_DATA_UPDATED, @@ -334,7 +333,7 @@ describe(useNodeDirtiness, () => { canvasOperations.setNodeParameters(workflowDocumentStore.nodesByName.e.id, { foo: 1 }); - expect(useNodeDirtiness().dirtinessByName.value).toEqual({ + expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({ // 'e' itself is not marked as parameters-updated, because it has no run data. f: CanvasNodeDirtiness.UPSTREAM_DIRTY, b: CanvasNodeDirtiness.UPSTREAM_DIRTY, @@ -348,7 +347,7 @@ describe(useNodeDirtiness, () => { trackHistory: true, }); - expect(useNodeDirtiness().dirtinessByName.value).toEqual({ + expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({ b: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED, }); }); @@ -360,7 +359,7 @@ describe(useNodeDirtiness, () => { trackHistory: true, }); - expect(useNodeDirtiness().dirtinessByName.value).toEqual({ + expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({ b: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED, }); }); @@ -372,7 +371,7 @@ describe(useNodeDirtiness, () => { canvasOperations.setNodeParameters(workflowDocumentStore.nodesByName.e.id, { foo: 1 }); - expect(useNodeDirtiness().dirtinessByName.value).toEqual({ + expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({ c: CanvasNodeDirtiness.UPSTREAM_DIRTY, e: CanvasNodeDirtiness.PARAMETERS_UPDATED, }); @@ -383,7 +382,7 @@ describe(useNodeDirtiness, () => { canvasOperations.setNodeParameters(workflowDocumentStore.nodesByName.c.id, { foo: 1 }); - expect(useNodeDirtiness().dirtinessByName.value).toEqual({ + expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({ c: CanvasNodeDirtiness.PARAMETERS_UPDATED, }); }); @@ -399,13 +398,13 @@ describe(useNodeDirtiness, () => { trackHistory: true, }); // 'a' becomes new parent of 'c' - expect(useNodeDirtiness().dirtinessByName.value).toEqual({ + expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({ c: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED, }); await canvasOperations.renameNode('c', 'd', { trackHistory: true }); - expect(useNodeDirtiness().dirtinessByName.value).toEqual({ + expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({ d: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED, }); }); diff --git a/packages/frontend/editor-ui/src/app/composables/useNodeDirtiness.ts b/packages/frontend/editor-ui/src/app/composables/useNodeDirtiness.ts index b2dae09b2e5..8beabffdb12 100644 --- a/packages/frontend/editor-ui/src/app/composables/useNodeDirtiness.ts +++ b/packages/frontend/editor-ui/src/app/composables/useNodeDirtiness.ts @@ -9,14 +9,17 @@ import { } from '@/app/models/history'; import { useHistoryStore } from '@/app/stores/history.store'; import { useWorkflowsStore } from '@/app/stores/workflows.store'; -import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store'; +import { + useWorkflowDocumentStore, + type WorkflowDocumentId, +} from '@/app/stores/workflowDocument.store'; import { CanvasNodeDirtiness, type CanvasNodeDirtinessType, } from '@/features/workflows/canvas/canvas.types'; import type { INodeConnections, NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionTypes } from 'n8n-workflow'; -import { computed } from 'vue'; +import { computed, toValue, type MaybeRefOrGetter } from 'vue'; /** * Does the command make the given node dirty? @@ -116,13 +119,29 @@ function findLoop( } /** - * Determines the subgraph that is affected by changes made after the last (partial) execution + * Determines the subgraph that is affected by changes made after the last (partial) execution. + * + * Takes the workflow document id (a value, ref, or getter) explicitly so it + * can resolve the document store directly via `useWorkflowDocumentStore(id)` + * instead of relying on `inject()`, which only resolves inside the + * `WorkflowLayout` tree. This makes the composable safe to call from + * off-layout contexts (e.g. the workflow-diff modal's `watchEffect`) without + * the try/catch fallback `useWorkflowDocumentRenderData` previously needed. + * + * Passing a reactive id lets callers bound to a swappable injected document + * store (e.g. push handlers that replace the current document while the host + * component stays mounted) re-resolve the store reactively. The internal + * computeds are built once and recompute against the active document, so the + * dirtiness tracks the live document without callers having to recreate the + * composable inside their own `computed`. */ -export function useNodeDirtiness() { +export function useNodeDirtiness(workflowDocumentId: MaybeRefOrGetter) { const historyStore = useHistoryStore(); const workflowsStore = useWorkflowsStore(); - const workflowDocumentStore = injectWorkflowDocumentStore(); + const workflowDocumentStore = computed(() => + useWorkflowDocumentStore(toValue(workflowDocumentId)), + ); function getIncomingConnections(nodeName: string): INodeConnections { return workflowDocumentStore.value.incomingConnectionsByNodeName(nodeName); diff --git a/packages/frontend/editor-ui/src/app/composables/useNodeHelpers.ts b/packages/frontend/editor-ui/src/app/composables/useNodeHelpers.ts index 6e660898b7e..e895d43eeb7 100644 --- a/packages/frontend/editor-ui/src/app/composables/useNodeHelpers.ts +++ b/packages/frontend/editor-ui/src/app/composables/useNodeHelpers.ts @@ -1,10 +1,6 @@ import { ref } from 'vue'; import { useHistoryStore } from '@/app/stores/history.store'; -import { - CUSTOM_API_CALL_KEY, - EnterpriseEditionFeature, - PLACEHOLDER_FILLED_AT_EXECUTION_TIME, -} from '@/app/constants'; +import { CUSTOM_API_CALL_KEY, EnterpriseEditionFeature } from '@/app/constants'; import { NodeHelpers, @@ -25,7 +21,6 @@ import type { IRunData, IBinaryKeyData, INode, - INodePropertyOptions, INodeCredentialsDetails, INodeParameters, INodeTypeNameVersion, @@ -43,7 +38,8 @@ import type { WorkflowObjectAccessors } from '@/app/types/workflow'; import { isString } from '@/app/utils/typeGuards'; import { isObject } from '@/app/utils/objectUtils'; -import { hasProxyAuth } from '@/app/utils/nodeTypesUtils'; +import { getNodeSubtitle, hasProxyAuth } from '@/app/utils/nodeTypesUtils'; +import { assignNodeId } from '@/app/utils/nodes/nodeTransforms'; import { useWorkflowsStore } from '@/app/stores/workflows.store'; import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; import { useCredentialsStore } from '@/features/credentials/credentials.store'; @@ -798,63 +794,6 @@ export function useNodeHelpers() { } } - function getNodeSubtitle( - data: INode, - nodeType: INodeTypeDescription, - workflow: WorkflowObjectAccessors, - ): string | undefined { - if (!data) { - return undefined; - } - - if (data.notesInFlow) { - return data.notes; - } - - if (nodeType?.subtitle !== undefined) { - try { - return workflow.expression.getSimpleParameterValue( - data, - nodeType.subtitle, - 'internal', - {}, - undefined, - PLACEHOLDER_FILLED_AT_EXECUTION_TIME, - ) as string | undefined; - } catch (e) { - return undefined; - } - } - - if (data.parameters.operation !== undefined) { - const operation = data.parameters.operation as string; - if (nodeType === null) { - return operation; - } - - const operationData = nodeType.properties.find((property: INodeProperties) => { - return property.name === 'operation'; - }); - if (operationData === undefined) { - return operation; - } - - if (operationData.options === undefined) { - return operation; - } - - const optionData = operationData.options.find((option) => { - return (option as INodePropertyOptions).value === data.parameters.operation; - }); - if (optionData === undefined) { - return operation; - } - - return optionData.name; - } - return undefined; - } - function matchCredentials(node: INodeUi) { if (!node.credentials) { return; @@ -935,12 +874,6 @@ export function useNodeHelpers() { } } - function assignNodeId(node: INodeUi) { - const id = window.crypto.randomUUID(); - node.id = id; - return id; - } - function assignWebhookId(node: INodeUi) { const id = window.crypto.randomUUID(); node.webhookId = id; diff --git a/packages/frontend/editor-ui/src/app/composables/useRunWorkflow.ts b/packages/frontend/editor-ui/src/app/composables/useRunWorkflow.ts index 9358dddd6c6..ddf46ff5d64 100644 --- a/packages/frontend/editor-ui/src/app/composables/useRunWorkflow.ts +++ b/packages/frontend/editor-ui/src/app/composables/useRunWorkflow.ts @@ -103,7 +103,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: useRunWorkflowOpts.router, }); const executionsStore = useExecutionsStore(); - const { dirtinessByName } = useNodeDirtiness(); + const { dirtinessByName } = useNodeDirtiness(() => workflowDocumentStore.value.documentId); const { startChat } = useCanvasOperations(); const chatStore = useChat(); diff --git a/packages/frontend/editor-ui/src/app/composables/useWorkflowUpdate.test.ts b/packages/frontend/editor-ui/src/app/composables/useWorkflowUpdate.test.ts index eff3c08279e..72e12374507 100644 --- a/packages/frontend/editor-ui/src/app/composables/useWorkflowUpdate.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/useWorkflowUpdate.test.ts @@ -70,8 +70,10 @@ vi.mock('@/app/composables/useCanvasOperations', () => ({ useCanvasOperations: vi.fn(() => mockCanvasOperations), })); -// Mock nodeTypesUtils -vi.mock('@/app/utils/nodeTypesUtils', () => ({ +// Mock nodeTypesUtils β€” keep real exports (e.g. getNodeSubtitle, used by +// useNodeHelpers) and override only the auth helpers under test. +vi.mock('@/app/utils/nodeTypesUtils', async (importOriginal) => ({ + ...(await importOriginal()), getMainAuthField: vi.fn(), getAuthTypeForNodeCredential: vi.fn(), })); diff --git a/packages/frontend/editor-ui/src/app/stores/executionData.store.test.ts b/packages/frontend/editor-ui/src/app/stores/executionData.store.test.ts index 4faed9862d7..411943c3051 100644 --- a/packages/frontend/editor-ui/src/app/stores/executionData.store.test.ts +++ b/packages/frontend/editor-ui/src/app/stores/executionData.store.test.ts @@ -1,12 +1,24 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { setActivePinia, createPinia, getActivePinia } from 'pinia'; +import { nextTick } from 'vue'; +import { NodeConnectionTypes, SEND_AND_WAIT_OPERATION, WAIT_INDEFINITELY } from 'n8n-workflow'; +import type { INode } from 'n8n-workflow'; import { useExecutionDataStore, createExecutionDataId, getExecutionDataStoreId, disposeExecutionDataStore, } from '@/app/stores/executionData.store'; -import { createTestWorkflowExecutionResponse } from '@/__tests__/mocks'; +import { + CANVAS_EXECUTION_DATA_THROTTLE_DURATION, + FORM_NODE_TYPE, + WAIT_NODE_TYPE, +} from '@/app/constants'; +import { + createTestNode, + createTestWorkflow, + createTestWorkflowExecutionResponse, +} from '@/__tests__/mocks'; import type { IExecutionResponse } from '@/features/execution/executions/executions.types'; function createTestExecution(overrides: Partial = {}): IExecutionResponse { @@ -774,6 +786,447 @@ describe('executionData.store', () => { expect(store.executionIssuesByNodeName.get('NodeA')?.value).toBe(before); }); }); + + // ------------------------------------------------------------------------- + // Per-node-id projections (moved here from useCanvasMapping). + // ------------------------------------------------------------------------- + + /** + * Builds an execution whose embedded `workflowData.nodes` snapshot provides + * the id↔name mapping the by-id projections key off, with `runData` keyed by + * node name as the server produces it. + */ + function setExecutionWithSnapshot( + store: ReturnType, + { + nodes, + runData = {}, + lastNodeExecuted = '', + ...executionOverrides + }: { + nodes: INode[]; + runData?: Record>>; + lastNodeExecuted?: string; + } & Partial, + ) { + store.setExecution( + createTestExecution({ + workflowData: createTestWorkflow({ nodes }), + data: { + resultData: { runData, lastNodeExecuted }, + } as never, + ...executionOverrides, + }), + ); + } + + describe('executionStatusByNodeId', () => { + const node = createTestNode({ id: 'node-1', name: 'Node 1' }); + + it('returns the last execution status when not canceled', () => { + const store = useExecutionDataStore(createExecutionDataId('exec-1')); + + setExecutionWithSnapshot(store, { + nodes: [node], + runData: { + 'Node 1': [{ executionStatus: 'success' }, { executionStatus: 'error' }], + }, + }); + + expect(store.executionStatusByNodeId.get('node-1')?.value).toBe('error'); + }); + + it('returns the second-to-last status when the last task is canceled and multiple tasks exist', () => { + const store = useExecutionDataStore(createExecutionDataId('exec-1')); + + setExecutionWithSnapshot(store, { + nodes: [node], + runData: { + 'Node 1': [{ executionStatus: 'success' }, { executionStatus: 'canceled' }], + }, + }); + + expect(store.executionStatusByNodeId.get('node-1')?.value).toBe('success'); + }); + + it('returns canceled when the only task is canceled', () => { + const store = useExecutionDataStore(createExecutionDataId('exec-1')); + + setExecutionWithSnapshot(store, { + nodes: [node], + runData: { + 'Node 1': [{ executionStatus: 'canceled' }], + }, + }); + + expect(store.executionStatusByNodeId.get('node-1')?.value).toBe('canceled'); + }); + + it('returns new when the node has no tasks', () => { + const store = useExecutionDataStore(createExecutionDataId('exec-1')); + + setExecutionWithSnapshot(store, { nodes: [node], runData: {} }); + + expect(store.executionStatusByNodeId.get('node-1')?.value).toBe('new'); + }); + + it('has no entry for nodes absent from the workflow snapshot', () => { + const store = useExecutionDataStore(createExecutionDataId('exec-1')); + + setExecutionWithSnapshot(store, { nodes: [node] }); + + expect(store.executionStatusByNodeId.has('unknown-node')).toBe(false); + }); + }); + + describe('executionWaitingByNodeId', () => { + const waitTill = new Date('2025-05-05T12:00:00.000Z'); + + function setWaitingExecution( + store: ReturnType, + node: INode, + overrides: Partial = {}, + ) { + setExecutionWithSnapshot(store, { + nodes: [node], + lastNodeExecuted: node.name, + finished: false, + ...({ waitTill } as Partial), + ...overrides, + }); + } + + it('returns the webhook message for a wait node resuming on webhook', () => { + const store = useExecutionDataStore(createExecutionDataId('exec-1')); + const node = createTestNode({ + id: 'wait-1', + name: 'Wait', + type: WAIT_NODE_TYPE, + parameters: { resume: 'webhook' }, + }); + + setWaitingExecution(store, node); + + expect(store.executionWaitingByNodeId.get('wait-1')?.value).toBe( + 'The node is waiting for an incoming webhook call', + ); + }); + + it('returns the form message for a wait node resuming on form submission', () => { + const store = useExecutionDataStore(createExecutionDataId('exec-1')); + const node = createTestNode({ + id: 'wait-1', + name: 'Wait', + type: WAIT_NODE_TYPE, + parameters: { resume: 'form' }, + }); + + setWaitingExecution(store, node); + + expect(store.executionWaitingByNodeId.get('wait-1')?.value).toBe( + 'The node is waiting for a form submission', + ); + }); + + it('returns the user-input message for sendAndWait operations', () => { + const store = useExecutionDataStore(createExecutionDataId('exec-1')); + const node = createTestNode({ + id: 'send-1', + name: 'Send', + parameters: { operation: SEND_AND_WAIT_OPERATION }, + }); + + setWaitingExecution(store, node); + + expect(store.executionWaitingByNodeId.get('send-1')?.value).toBe( + 'The node is waiting for user input', + ); + }); + + it('returns the form message for form nodes', () => { + const store = useExecutionDataStore(createExecutionDataId('exec-1')); + const node = createTestNode({ id: 'form-1', name: 'Form', type: FORM_NODE_TYPE }); + + setWaitingExecution(store, node); + + expect(store.executionWaitingByNodeId.get('form-1')?.value).toBe( + 'The node is waiting for a form submission', + ); + }); + + it('returns the indefinite message when waiting indefinitely', () => { + const store = useExecutionDataStore(createExecutionDataId('exec-1')); + const node = createTestNode({ id: 'wait-1', name: 'Wait', type: WAIT_NODE_TYPE }); + + setWaitingExecution(store, node, { + ...({ waitTill: WAIT_INDEFINITELY } as Partial), + }); + + expect(store.executionWaitingByNodeId.get('wait-1')?.value).toBe( + 'The node is waiting for an incoming webhook call (indefinitely)', + ); + }); + + it('returns a dated message for a timed wait', () => { + const store = useExecutionDataStore(createExecutionDataId('exec-1')); + const node = createTestNode({ id: 'wait-1', name: 'Wait', type: WAIT_NODE_TYPE }); + + setWaitingExecution(store, node); + + expect(store.executionWaitingByNodeId.get('wait-1')?.value).toMatch( + /^Node is waiting until /, + ); + }); + + it('returns undefined when the execution is finished', () => { + const store = useExecutionDataStore(createExecutionDataId('exec-1')); + const node = createTestNode({ id: 'wait-1', name: 'Wait', type: WAIT_NODE_TYPE }); + + setWaitingExecution(store, node, { finished: true }); + + expect(store.executionWaitingByNodeId.get('wait-1')?.value).toBeUndefined(); + }); + + it('returns undefined when the node is not the last node executed', () => { + const store = useExecutionDataStore(createExecutionDataId('exec-1')); + const node = createTestNode({ id: 'wait-1', name: 'Wait', type: WAIT_NODE_TYPE }); + + setWaitingExecution(store, node, { lastNodeExecuted: 'Another Node' } as never); + + expect(store.executionWaitingByNodeId.get('wait-1')?.value).toBeUndefined(); + }); + + it('returns undefined when the execution has no waitTill', () => { + const store = useExecutionDataStore(createExecutionDataId('exec-1')); + const node = createTestNode({ id: 'wait-1', name: 'Wait', type: WAIT_NODE_TYPE }); + + setExecutionWithSnapshot(store, { + nodes: [node], + lastNodeExecuted: node.name, + finished: false, + }); + + expect(store.executionWaitingByNodeId.get('wait-1')?.value).toBeUndefined(); + }); + }); + + describe('executionRunDataOutputMapByNodeId', () => { + // The rebuild runs behind a throttledWatch whose leading slot is consumed + // by the `immediate: true` run at store creation, so a setExecution right + // after creation lands on the trailing edge. Fake timers let tests skip + // the throttle window deterministically. + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + async function flushOutputMapRebuild() { + // Let the (pre-flush) watcher run, then fire the trailing throttle slot. + await nextTick(); + vi.advanceTimersByTime(CANVAS_EXECUTION_DATA_THROTTLE_DURATION); + } + + function createTask( + items: number, + overrides: Record = {}, + connectionType: string = NodeConnectionTypes.Main, + ) { + return { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [], + data: { + [connectionType]: [Array.from({ length: items }, () => ({ json: {} }))], + }, + ...overrides, + }; + } + + it('is empty when there is no run data', () => { + const store = useExecutionDataStore(createExecutionDataId('exec-1')); + + expect(store.executionRunDataOutputMapByNodeId.size).toBe(0); + }); + + it('calculates iterations and total for a single node', async () => { + const store = useExecutionDataStore(createExecutionDataId('exec-1')); + const node = createTestNode({ id: 'node-1', name: 'Node 1' }); + + setExecutionWithSnapshot(store, { + nodes: [node], + runData: { 'Node 1': [createTask(2)] }, + }); + await flushOutputMapRebuild(); + + expect(store.executionRunDataOutputMapByNodeId.get('node-1')).toEqual({ + [NodeConnectionTypes.Main]: { 0: { iterations: 1, total: 2 } }, + }); + }); + + it('aggregates multiple iterations', async () => { + const store = useExecutionDataStore(createExecutionDataId('exec-1')); + const node = createTestNode({ id: 'node-1', name: 'Node 1' }); + + setExecutionWithSnapshot(store, { + nodes: [node], + runData: { + 'Node 1': [ + createTask(1), + createTask(3, { executionIndex: 1 }), + createTask(2, { executionIndex: 2 }), + ], + }, + }); + await flushOutputMapRebuild(); + + expect(store.executionRunDataOutputMapByNodeId.get('node-1')).toEqual({ + [NodeConnectionTypes.Main]: { 0: { iterations: 3, total: 6 } }, + }); + }); + + it('does not count canceled iterations but still counts their data', async () => { + const store = useExecutionDataStore(createExecutionDataId('exec-1')); + const node = createTestNode({ id: 'node-1', name: 'Node 1' }); + + setExecutionWithSnapshot(store, { + nodes: [node], + runData: { + 'Node 1': [ + createTask(2, { executionStatus: 'success' }), + createTask(3, { executionStatus: 'canceled', executionIndex: 1 }), + createTask(1, { executionStatus: 'success', executionIndex: 2 }), + ], + }, + }); + await flushOutputMapRebuild(); + + expect(store.executionRunDataOutputMapByNodeId.get('node-1')).toEqual({ + [NodeConnectionTypes.Main]: { 0: { iterations: 2, total: 6 } }, + }); + }); + + it('reports zero iterations when all iterations are canceled', async () => { + const store = useExecutionDataStore(createExecutionDataId('exec-1')); + const node = createTestNode({ id: 'node-1', name: 'Node 1' }); + + setExecutionWithSnapshot(store, { + nodes: [node], + runData: { + 'Node 1': [ + createTask(2, { executionStatus: 'canceled' }), + createTask(1, { executionStatus: 'canceled', executionIndex: 1 }), + ], + }, + }); + await flushOutputMapRebuild(); + + expect(store.executionRunDataOutputMapByNodeId.get('node-1')).toEqual({ + [NodeConnectionTypes.Main]: { 0: { iterations: 0, total: 3 } }, + }); + }); + + it('populates byTarget per-target counts for non-main connections', async () => { + const store = useExecutionDataStore(createExecutionDataId('exec-1')); + const modelNode = createTestNode({ id: 'model-1', name: 'OpenAI Chat Model' }); + const agent1Node = createTestNode({ id: 'agent-1', name: 'AI Agent 1' }); + const agent2Node = createTestNode({ id: 'agent-2', name: 'AI Agent 2' }); + + setExecutionWithSnapshot(store, { + nodes: [modelNode, agent1Node, agent2Node], + runData: { + 'OpenAI Chat Model': [ + createTask( + 2, + { executionStatus: 'success', source: [{ previousNode: 'AI Agent 1' }] }, + NodeConnectionTypes.AiLanguageModel, + ), + createTask( + 1, + { + executionStatus: 'success', + executionIndex: 1, + source: [{ previousNode: 'AI Agent 2' }], + }, + NodeConnectionTypes.AiLanguageModel, + ), + createTask( + 3, + { + executionStatus: 'success', + executionIndex: 2, + source: [{ previousNode: 'AI Agent 1' }], + }, + NodeConnectionTypes.AiLanguageModel, + ), + ], + }, + }); + await flushOutputMapRebuild(); + + const outputData = + store.executionRunDataOutputMapByNodeId.get('model-1')?.[ + NodeConnectionTypes.AiLanguageModel + ]?.[0]; + + expect(outputData?.iterations).toBe(3); + expect(outputData?.total).toBe(6); + // Agent 1 was called twice with 2 + 3 = 5 items; agent 2 once with 1. + expect(outputData?.byTarget?.['agent-1']).toEqual({ iterations: 2, total: 5 }); + expect(outputData?.byTarget?.['agent-2']).toEqual({ iterations: 1, total: 1 }); + }); + + it('counts items inside the response field for non-main connections', async () => { + const store = useExecutionDataStore(createExecutionDataId('exec-1')); + const embeddingNode = createTestNode({ id: 'embed-1', name: 'Embeddings OpenAI' }); + const vectorStoreNode = createTestNode({ id: 'vector-1', name: 'Vector Store' }); + + setExecutionWithSnapshot(store, { + nodes: [embeddingNode, vectorStoreNode], + runData: { + 'Embeddings OpenAI': [ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + executionStatus: 'success', + source: [{ previousNode: 'Vector Store' }], + data: { + [NodeConnectionTypes.AiEmbedding]: [ + [ + { + json: { + response: [ + { embedding: [0.1, 0.2] }, + { embedding: [0.3, 0.4] }, + { embedding: [0.5, 0.6] }, + ], + }, + }, + ], + ], + }, + }, + ], + }, + }); + await flushOutputMapRebuild(); + + const outputData = + store.executionRunDataOutputMapByNodeId.get('embed-1')?.[ + NodeConnectionTypes.AiEmbedding + ]?.[0]; + + // Counts the 3 items inside `response`, not the 1 wrapper item β€” also + // for the per-target counts. + expect(outputData?.iterations).toBe(1); + expect(outputData?.total).toBe(3); + expect(outputData?.byTarget?.['vector-1']).toEqual({ iterations: 1, total: 3 }); + }); + }); }); async function flushPromises() { diff --git a/packages/frontend/editor-ui/src/app/stores/executionData.store.ts b/packages/frontend/editor-ui/src/app/stores/executionData.store.ts index 0996943484b..1a03c038e9e 100644 --- a/packages/frontend/editor-ui/src/app/stores/executionData.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/executionData.store.ts @@ -2,18 +2,35 @@ import { defineStore, getActivePinia } from 'pinia'; import { STORES } from '@n8n/stores'; import { computed, effectScope, inject, readonly, ref, shallowReactive } from 'vue'; import type { ComputedRef } from 'vue'; -import { createEventHook } from '@vueuse/core'; +import { createEventHook, throttledWatch } from '@vueuse/core'; import { structuralComputed } from '@n8n/composables/structuralComputed'; import isEqual from 'lodash/isEqual'; -import type { ExecutionStatus, IRunData, IRunExecutionData, ITaskStartedData } from 'n8n-workflow'; +import { useI18n } from '@n8n/i18n'; +import { + NodeConnectionTypes, + SEND_AND_WAIT_OPERATION, + WAIT_INDEFINITELY, + type ExecutionStatus, + type INode, + type IRunData, + type IRunExecutionData, + type ITaskData, + type ITaskStartedData, +} from 'n8n-workflow'; import type { PushPayload } from '@n8n/api-types'; import type { NodeExecuteBefore } from '@n8n/api-types/push/execution'; import type { IExecutionResponse } from '@/features/execution/executions/executions.types'; import { ExecutionDataStoreKey } from '@/app/constants/injectionKeys'; +import { + CANVAS_EXECUTION_DATA_THROTTLE_DURATION, + FORM_NODE_TYPE, + WAIT_NODE_TYPE, +} from '@/app/constants'; import { getPairedItemsMapping } from '@/app/utils/pairedItemUtils'; import { sanitizeHtml } from '@/app/utils/htmlUtils'; import { CHANGE_ACTION } from './workflowDocument/types'; import type { ChangeAction, ChangeEvent } from './workflowDocument/types'; +import type { ExecutionOutputMap } from '@/app/types/executionData'; export type ExecutionDataId = string; @@ -57,6 +74,7 @@ export function getExecutionDataStoreId(id: ExecutionDataId) { */ export function useExecutionDataStore(id: ExecutionDataId) { return defineStore(getExecutionDataStoreId(id), () => { + const i18n = useI18n(); const execution = ref(null); const executionResultDataLastUpdate = ref(); const executionStartedData = ref(); @@ -137,6 +155,264 @@ export function useExecutionDataStore(id: ExecutionDataId) { void onExecutionDataChange.on(reconcileExecutionIssuesFromRunData); reconcileExecutionIssuesFromRunData(); + // --------------------------------------------------------------------- + // Per-node-id projections of execution state. + // + // The store's primary key is node *name* (runData is keyed by name), + // but consumers (canvas, NDV) need by-id lookup. The execution payload + // embeds a `workflowData.nodes` snapshot, which is the authoritative + // source for the name↔id mapping at execution time. `renameExecutionDataNode` + // updates that snapshot in place, so renames propagate naturally. + // + // Each map uses the same shallowReactive>> + // pattern as `executionIssuesByNodeName`: per-entry structuralComputed, + // reconciled against `workflowData.nodes` via `onExecutionDataChange`. + // --------------------------------------------------------------------- + + // Lazy index of the embedded workflow snapshot, keyed by node id. + // Materializes once per execution.value change and is shared across all + // per-entry projections below β€” collapses id β†’ node resolution from + // O(N) per lookup to O(1). + const executionNodeById = computed(() => { + const map = new Map(); + const nodes = execution.value?.workflowData?.nodes; + if (nodes) for (const n of nodes) map.set(n.id, n); + return map; + }); + + function getExecutionNodeById(nodeId: string): INode | undefined { + return executionNodeById.value.get(nodeId); + } + + function getExecutionNodeIds(): string[] { + return Array.from(executionNodeById.value.keys()); + } + + const byIdScopes = new Map void>(); + const executionStatusByNodeId = shallowReactive( + new Map>(), + ); + const executionRunDataByNodeId = shallowReactive( + new Map>(), + ); + const executionWaitingByNodeId = shallowReactive( + new Map>(), + ); + + function computeExecutionStatus(nodeId: string): ExecutionStatus { + const node = getExecutionNodeById(nodeId); + if (!node) return 'new'; + const tasks = executionRunData.value?.[node.name] ?? []; + // A canceled top-of-stack tends to mask the prior "real" status β€” peek + // one task back so the UI shows the meaningful state. + let status = tasks.at(-1)?.executionStatus; + if (tasks.length > 1 && status === 'canceled') { + status = tasks.at(-2)?.executionStatus; + } + return status ?? 'new'; + } + + function computeExecutionRunData(nodeId: string): ITaskData[] | null { + const node = getExecutionNodeById(nodeId); + if (!node) return null; + const tasks = executionRunData.value?.[node.name]; + return tasks ?? null; + } + + function computeExecutionWaiting(nodeId: string): string | undefined { + const node = getExecutionNodeById(nodeId); + if (!node) return undefined; + const ex = execution.value; + if (!ex || ex.finished) return undefined; + // `waitTill` exists at runtime on resumed executions but isn't declared + // on IExecutionResponse β€” narrow via the property check. + const waitTill = (ex as IExecutionResponse & { waitTill?: Date | string }).waitTill; + if (!waitTill) return undefined; + const lastNodeExecuted = ex.data?.resultData?.lastNodeExecuted; + if (node.name !== lastNodeExecuted) return undefined; + + const resume = node.parameters?.resume as string | undefined; + if (node.type === WAIT_NODE_TYPE && (resume === 'webhook' || resume === 'form')) { + return resume === 'webhook' + ? i18n.baseText('node.theNodeIsWaitingWebhookCall') + : i18n.baseText('node.theNodeIsWaitingFormCall'); + } + if (node.parameters?.operation === SEND_AND_WAIT_OPERATION) { + return i18n.baseText('node.theNodeIsWaitingUserInput'); + } + if (node.type === FORM_NODE_TYPE) { + return i18n.baseText('node.theNodeIsWaitingFormCall'); + } + const waitDate = new Date(waitTill); + if (waitDate.getTime() === WAIT_INDEFINITELY.getTime()) { + return i18n.baseText('node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall'); + } + return i18n.baseText('node.nodeIsWaitingTill', { + interpolate: { + date: waitDate.toLocaleDateString(), + time: waitDate.toLocaleTimeString(), + }, + }); + } + + function applyAddByIdEntry(nodeId: string) { + if (byIdScopes.has(nodeId)) return; + const scope = effectScope(); + scope.run(() => { + executionStatusByNodeId.set( + nodeId, + structuralComputed(() => computeExecutionStatus(nodeId)), + ); + // Plain `computed` (Object.is) rather than `structuralComputed(..., isEqual)`: + // per-task data can be megabytes for nodes with large outputs, and + // every push replaces the runData array reference with new content + // β€” so an isEqual gate would deep-compare megabytes and never + // short-circuit. Reference identity is the right gate here. + executionRunDataByNodeId.set( + nodeId, + computed(() => computeExecutionRunData(nodeId)), + ); + executionWaitingByNodeId.set( + nodeId, + structuralComputed(() => computeExecutionWaiting(nodeId)), + ); + }); + byIdScopes.set(nodeId, () => scope.stop()); + } + + function applyRemoveByIdEntry(nodeId: string) { + byIdScopes.get(nodeId)?.(); + byIdScopes.delete(nodeId); + executionStatusByNodeId.delete(nodeId); + executionRunDataByNodeId.delete(nodeId); + executionWaitingByNodeId.delete(nodeId); + } + + function applyReconcileByIdEntries(nodeIds: string[]) { + const next = new Set(nodeIds); + for (const old of byIdScopes.keys()) { + if (!next.has(old)) applyRemoveByIdEntry(old); + } + for (const id of nodeIds) applyAddByIdEntry(id); + } + + function reconcileByIdEntries() { + applyReconcileByIdEntries(getExecutionNodeIds()); + } + + void onExecutionDataChange.on(reconcileByIdEntries); + reconcileByIdEntries(); + + // Throttled per-node-id aggregation of run data into `ExecutionOutputMap`. + // Aggregation cost scales with task list length and item counts; rebuilding + // on every push during a fast execution would be expensive. The throttle + // batches updates; consumers (label rendering, item counters) only invalidate + // when their specific id slot changes, gated by `isEqual` per node. + const executionRunDataOutputMapByNodeId = shallowReactive( + new Map(), + ); + + function rebuildExecutionRunDataOutputMap() { + const runData = executionRunData.value; + const snapshotNodes = execution.value?.workflowData?.nodes ?? []; + if (!runData) { + for (const k of Array.from(executionRunDataOutputMapByNodeId.keys())) { + executionRunDataOutputMapByNodeId.delete(k); + } + return; + } + + const nameToId = new Map(snapshotNodes.map((n) => [n.name, n.id])); + const next = new Map(); + + for (const nodeName of Object.keys(runData)) { + const nodeId = nameToId.get(nodeName); + if (!nodeId) continue; + + const agg: ExecutionOutputMap = {}; + const taskList = runData[nodeName] ?? []; + + for (const runIteration of taskList) { + const data = runIteration.data ?? {}; + for (const connectionType of Object.keys(data)) { + const connectionTypeData = data[connectionType] ?? {}; + agg[connectionType] = agg[connectionType] ?? {}; + + for (const outputIndex of Object.keys(connectionTypeData)) { + const parsedOutputIndex = parseInt(outputIndex, 10); + const items = connectionTypeData[parsedOutputIndex] ?? []; + + agg[connectionType][outputIndex] = agg[connectionType][outputIndex] ?? { + total: 0, + iterations: 0, + ...(connectionType !== NodeConnectionTypes.Main ? { byTarget: {} } : {}), + }; + + // Non-main connections (AI tools/memory/embeddings) wrap items + // in a `response` array; the apparent itemCount is the length + // of that array, not the outer items list. Check only the first + // item assuming uniform structure. + let itemCount = items.length; + if (connectionType !== NodeConnectionTypes.Main && items.length > 0) { + const first = items[0]; + if ( + first?.json && + typeof first.json === 'object' && + 'response' in first.json && + Array.isArray((first.json as { response: unknown[] }).response) + ) { + itemCount = (first.json as { response: unknown[] }).response.length; + } + } + + if (runIteration.executionStatus !== 'canceled') { + agg[connectionType][outputIndex].iterations += 1; + } + agg[connectionType][outputIndex].total += itemCount; + + if (connectionType !== NodeConnectionTypes.Main) { + const callingNodeName = runIteration.source?.[0]?.previousNode; + if (callingNodeName) { + const targetId = nameToId.get(callingNodeName); + if (targetId) { + const entry = agg[connectionType][outputIndex]; + entry.byTarget = entry.byTarget ?? {}; + entry.byTarget[targetId] = entry.byTarget[targetId] ?? { + total: 0, + iterations: 0, + }; + if (runIteration.executionStatus !== 'canceled') { + entry.byTarget[targetId].iterations += 1; + } + entry.byTarget[targetId].total += itemCount; + } + } + } + } + } + } + next.set(nodeId, agg); + } + + // Reconcile to the shallowReactive map for per-id reactivity. isEqual + // skips structurally-identical slots so downstream consumers don't + // invalidate when their node's aggregation hasn't actually changed. + for (const oldId of Array.from(executionRunDataOutputMapByNodeId.keys())) { + if (!next.has(oldId)) executionRunDataOutputMapByNodeId.delete(oldId); + } + for (const [nodeId, value] of next.entries()) { + const existing = executionRunDataOutputMapByNodeId.get(nodeId); + if (!existing || !isEqual(existing, value)) { + executionRunDataOutputMapByNodeId.set(nodeId, value); + } + } + } + + throttledWatch(executionResultDataLastUpdate, rebuildExecutionRunDataOutputMap, { + throttle: CANVAS_EXECUTION_DATA_THROTTLE_DURATION, + immediate: true, + }); + function fireChange(action: ChangeAction, nodeName?: string) { void onExecutionDataChange.trigger({ action, @@ -415,6 +691,10 @@ export function useExecutionDataStore(id: ExecutionDataId) { executionRunData, executedNode, executionIssuesByNodeName, + executionStatusByNodeId, + executionRunDataByNodeId, + executionWaitingByNodeId, + executionRunDataOutputMapByNodeId, executionStartedData: readonly(executionStartedData), executionPairedItemMappings: readonly(executionPairedItemMappings), getExecutionRunDataByNodeName, diff --git a/packages/frontend/editor-ui/src/app/stores/workflowDocument.store.ts b/packages/frontend/editor-ui/src/app/stores/workflowDocument.store.ts index 0a9c8a683cf..0d87ca61122 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflowDocument.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/workflowDocument.store.ts @@ -34,12 +34,11 @@ import { useWorkflowDocumentNodeGroups } from './workflowDocument/useWorkflowDoc import { CHANGE_ACTION } from './workflowDocument/types'; import { useUIStore } from '@/app/stores/ui.store'; import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; -import { useNodeHelpers } from '@/app/composables/useNodeHelpers'; -import { serializeNode } from '@/app/utils/nodes/nodeTransforms'; +import { assignNodeId, serializeNode } from '@/app/utils/nodes/nodeTransforms'; import type { WorkflowObjectAccessors } from '../types'; import type { IWorkflowDb } from '@/Interface'; import type { INode, ProjectSharingData } from 'n8n-workflow'; -import { deepCopy } from 'n8n-workflow'; +import { deepCopy, nodeIssuesToString } from 'n8n-workflow'; import type { WorkflowData } from '@n8n/rest-api-client/api/workflows'; import type { Scope } from '@n8n/permissions'; import type { IUsedCredential } from '@/features/credentials/credentials.types'; @@ -141,7 +140,6 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) { const [workflowId, workflowVersion] = id.split('@'); const nodeTypesStore = useNodeTypesStore(); - const nodeHelpers = useNodeHelpers(); const { cloneWorkflowObject, createWorkflowObject, ...workflowDocumentWorkflowObject } = useWorkflowDocumentWorkflowObject({ workflowId }); @@ -157,7 +155,6 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) { const workflowDocumentMeta = useWorkflowDocumentMeta(); const workflowDocumentTags = useWorkflowDocumentTags(); const workflowDocumentIsArchived = useWorkflowDocumentIsArchived(); - const workflowDocumentPinData = useWorkflowDocumentPinData(); const workflowDocumentScopes = useWorkflowDocumentScopes(); const workflowDocumentTimestamps = useWorkflowDocumentTimestamps(); const workflowDocumentSettings = useWorkflowDocumentSettings({ @@ -172,11 +169,16 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) { const { onStateDirty: onNodesStateDirty, ...workflowDocumentNodes } = useWorkflowDocumentNodes({ getNodeType: (typeName, version) => nodeTypesStore.getNodeType(typeName, version), nodeMetadata: workflowDocumentNodeMetadata, - assignNodeId: (node) => nodeHelpers.assignNodeId(node), + assignNodeId, syncWorkflowObject: (nodes) => workflowDocumentWorkflowObject.syncWorkflowObjectNodes(nodes), - unpinNodeData: (name) => workflowDocumentPinData.unpinNodeData(name), workflowObject: workflowDocumentWorkflowObject.workflowObject, }); + // pinData subscribes to nodes' `onNodesChange` and clears orphan pin data + // on DELETE itself β€” so nodes doesn't need a reverse dep into pinData. + const workflowDocumentPinData = useWorkflowDocumentPinData({ + nodesById: workflowDocumentNodes.nodesById, + onNodesChange: workflowDocumentNodes.onNodesChange, + }); const { onStateDirty: onConnectionsStateDirty, ...workflowDocumentConnections } = useWorkflowDocumentConnections({ getNodeById: (id) => workflowDocumentNodes.getNodeById(id), @@ -193,6 +195,9 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) { allNodes: workflowDocumentNodes.allNodes, outgoingConnectionsByNodeName: workflowDocumentConnections.outgoingConnectionsByNodeName, incomingConnectionsByNodeName: workflowDocumentConnections.incomingConnectionsByNodeName, + nodesById: workflowDocumentNodes.nodesById, + onNodesChange: workflowDocumentNodes.onNodesChange, + nodeIssuesToString, }); const { onStateDirty: onNodeGroupsStateDirty, ...workflowDocumentNodeGroups } = useWorkflowDocumentNodeGroups(); diff --git a/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentGraph.test.ts b/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentGraph.test.ts index 6fe12aa2a1d..e2c6319434b 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentGraph.test.ts +++ b/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentGraph.test.ts @@ -46,7 +46,6 @@ function createNodesDeps( getNodeType: vi.fn().mockReturnValue(null), assignNodeId: vi.fn().mockReturnValue(''), syncWorkflowObject: vi.fn(), - unpinNodeData: vi.fn(), nodeMetadata: useWorkflowDocumentNodeMetadata(), workflowObject: obj.workflowObject, }; diff --git a/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentNodes.test.ts b/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentNodes.test.ts index f25aebefd48..834c4dfc39d 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentNodes.test.ts +++ b/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentNodes.test.ts @@ -46,7 +46,6 @@ function createDeps(overrides: Partial = {}): Workflo getNodeType: vi.fn().mockReturnValue(null), assignNodeId: vi.fn().mockReturnValue(''), syncWorkflowObject: vi.fn(), - unpinNodeData: vi.fn(), nodeMetadata: useWorkflowDocumentNodeMetadata(), workflowObject: ref( mock({ getNode: () => null }), @@ -588,32 +587,9 @@ describe('useWorkflowDocumentNodes', () => { expect(dirtySpy).not.toHaveBeenCalled(); }); - it('removeNode calls unpinNodeData', () => { - const node = createNode({ name: 'Target' }); - - const workflowDocumentNodes = useWorkflowDocumentNodes(deps); - workflowDocumentNodes.setNodes([node]); - workflowDocumentNodes.removeNode(node); - - expect(deps.unpinNodeData).toHaveBeenCalledWith('Target'); - }); - - it('removeNodeById calls unpinNodeData', () => { - const node = createNode({ name: 'Target' }); - - const workflowDocumentNodes = useWorkflowDocumentNodes(deps); - workflowDocumentNodes.setNodes([node]); - workflowDocumentNodes.removeNodeById(node.id); - - expect(deps.unpinNodeData).toHaveBeenCalledWith('Target'); - }); - - it('removeNodeById does not call unpinNodeData when node not found', () => { - const workflowDocumentNodes = useWorkflowDocumentNodes(deps); - workflowDocumentNodes.removeNodeById('nonexistent'); - - expect(deps.unpinNodeData).not.toHaveBeenCalled(); - }); + // Orphan-pin-cleanup coverage (previously a direct dep of useWorkflowDocumentNodes + // via `unpinNodeData`) now lives in useWorkflowDocumentPinData.test.ts β€” + // pinData subscribes to onNodesChange and handles DELETE itself. it('removeNodeById uses empty name when node not found', () => { const hookSpy = vi.fn(); diff --git a/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentNodes.ts b/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentNodes.ts index 688fd05a158..961f89a9dbe 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentNodes.ts +++ b/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentNodes.ts @@ -61,7 +61,6 @@ export interface WorkflowDocumentNodesDeps { getNodeType: (typeName: string, version?: number) => INodeTypeDescription | null; assignNodeId: (node: INodeUi) => string; syncWorkflowObject: (nodes: INodeUi[]) => void; - unpinNodeData: (name: string) => void; nodeMetadata: ReturnType; workflowObject: Ref; } @@ -169,7 +168,6 @@ export function useWorkflowDocumentNodes(deps: WorkflowDocumentNodesDeps) { deps.syncWorkflowObject(nodes.value); deps.nodeMetadata.removeNodeMetadata(node.name); - deps.unpinNodeData(node.name); void onNodesChange.trigger({ action: CHANGE_ACTION.DELETE, payload: { name: node.name, id: node.id }, @@ -186,7 +184,6 @@ export function useWorkflowDocumentNodes(deps: WorkflowDocumentNodesDeps) { deps.syncWorkflowObject(nodes.value); if (node) { deps.nodeMetadata.removeNodeMetadata(node.name); - deps.unpinNodeData(node.name); } void onNodesChange.trigger({ action: CHANGE_ACTION.DELETE, diff --git a/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentNodesIssues.test.ts b/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentNodesIssues.test.ts index 56c714cefdc..9a83d97303c 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentNodesIssues.test.ts +++ b/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentNodesIssues.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { computed } from 'vue'; +import { computed, shallowRef } from 'vue'; import { createTestNode } from '@/__tests__/mocks'; import type { INodeUi } from '@/Interface'; import type { INodeConnections } from 'n8n-workflow'; @@ -21,6 +21,9 @@ function createDeps(nodes: INodeUi[], connected = true): WorkflowDocumentNodesIs allNodes: computed(() => nodes), outgoingConnectionsByNodeName: connected ? hasConnections : noConnections, incomingConnectionsByNodeName: noConnections, + nodesById: shallowRef(new Map(nodes.map((n) => [n.id, n]))), + onNodesChange: () => {}, + nodeIssuesToString: () => [], }; } diff --git a/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentNodesIssues.ts b/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentNodesIssues.ts index 7b7a9144694..9aa9ff9cdfb 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentNodesIssues.ts +++ b/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentNodesIssues.ts @@ -1,11 +1,23 @@ -import { computed, type ComputedRef } from 'vue'; +import { computed, effectScope, shallowReactive, type ComputedRef, type ShallowRef } from 'vue'; +import { structuralComputed } from '@n8n/composables/structuralComputed'; +import isEqual from 'lodash/isEqual'; import type { INodeUi, WorkflowValidationIssue } from '@/Interface'; -import type { INodeConnections } from 'n8n-workflow'; +import type { INodeConnections, INodeIssues, INode } from 'n8n-workflow'; +import { CHANGE_ACTION } from './types'; +import type { + NodeAddedPayload, + NodeRemovedPayload, + NodesChangeEvent, + NodesSetPayload, +} from './useWorkflowDocumentNodes'; export type WorkflowDocumentNodesIssuesDeps = { allNodes: ComputedRef; outgoingConnectionsByNodeName: (nodeName: string) => INodeConnections; incomingConnectionsByNodeName: (nodeName: string) => INodeConnections; + nodesById: ShallowRef>; + onNodesChange: (cb: (event: NodesChangeEvent) => void) => void; + nodeIssuesToString: (issues: INodeIssues, node?: INode) => string[]; }; export function useWorkflowDocumentNodesIssues(deps: WorkflowDocumentNodesIssuesDeps) { @@ -88,12 +100,76 @@ export function useWorkflowDocumentNodesIssues(deps: WorkflowDocumentNodesIssues return String(issue); } + // Per-node-id validation errors map. See useWorkflowDocumentRenderData + // for an explanation of the shallowReactive + structuralComputed pattern. + const validationErrorsByNodeId = shallowReactive(new Map>()); + const scopes = new Map void>(); + + function computeValidationErrors(nodeId: string): string[] { + const node = deps.nodesById.value.get(nodeId); + if (!node?.issues) return []; + return deps.nodeIssuesToString(node.issues, node); + } + + function applyAddEntry(nodeId: string) { + if (scopes.has(nodeId)) return; + const scope = effectScope(); + scope.run(() => { + validationErrorsByNodeId.set( + nodeId, + structuralComputed(() => computeValidationErrors(nodeId), isEqual), + ); + }); + scopes.set(nodeId, () => scope.stop()); + } + + function applyRemoveEntry(nodeId: string) { + scopes.get(nodeId)?.(); + scopes.delete(nodeId); + validationErrorsByNodeId.delete(nodeId); + } + + function applyReconcileEntries(nodeIds: string[]) { + const nextIds = new Set(nodeIds); + for (const oldId of scopes.keys()) { + if (!nextIds.has(oldId)) applyRemoveEntry(oldId); + } + for (const id of nodeIds) applyAddEntry(id); + } + + deps.onNodesChange((event) => { + switch (event.action) { + case CHANGE_ACTION.ADD: { + const { node } = event.payload as NodeAddedPayload; + applyAddEntry(node.id); + break; + } + case CHANGE_ACTION.DELETE: { + const payload = event.payload as NodeRemovedPayload; + if (payload.id) { + applyRemoveEntry(payload.id); + } else { + applyReconcileEntries([]); + } + break; + } + case CHANGE_ACTION.SET: { + const { nodeIds } = event.payload as NodesSetPayload; + applyReconcileEntries(nodeIds); + break; + } + } + }); + + applyReconcileEntries(Array.from(deps.nodesById.value.keys())); + return { nodesWithValidationIssues, nodesWithValidationIssuesCount, hasNodeValidationIssues, hasPublishBlockingIssues, nodeValidationIssues, + validationErrorsByNodeId, formatNodeIssueMessage, }; } diff --git a/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentPinData.test.ts b/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentPinData.test.ts index 859facedc6d..382b00e0fa5 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentPinData.test.ts +++ b/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentPinData.test.ts @@ -1,13 +1,32 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { shallowRef } from 'vue'; import type { INodeExecutionData, IPinData } from 'n8n-workflow'; +import type { INodeUi } from '@/Interface'; import { useWorkflowDocumentPinData, getPinDataSize, pinDataToExecutionData, } from './useWorkflowDocumentPinData'; +import { CHANGE_ACTION } from './types'; +import type { NodesChangeEvent } from './useWorkflowDocumentNodes'; function createPinData() { - return useWorkflowDocumentPinData(); + return useWorkflowDocumentPinData({ + nodesById: shallowRef(new Map()), + onNodesChange: () => {}, + }); +} + +function createPinDataWithNodesChange() { + const subscribers: Array<(event: NodesChangeEvent) => void> = []; + const pinData = useWorkflowDocumentPinData({ + nodesById: shallowRef(new Map()), + onNodesChange: (cb) => { + subscribers.push(cb); + }, + }); + const fire = (event: NodesChangeEvent) => subscribers.forEach((cb) => cb(event)); + return { pinData, fire }; } describe('useWorkflowDocumentPinData', () => { @@ -389,4 +408,39 @@ describe('getPinDataSize', () => { expect(twoNodes).toBeGreaterThan(singleNode); }); + + describe('orphan pin cleanup on node removal', () => { + it('clears pinned data when a DELETE event names the node', () => { + const { pinData, fire } = createPinDataWithNodesChange(); + pinData.pinNodeData('Target', [{ json: { x: 1 } }] as INodeExecutionData[]); + expect(pinData.pinnedDataByNodeName.value.Target).toBeDefined(); + + fire({ action: CHANGE_ACTION.DELETE, payload: { id: 'node-id-1', name: 'Target' } }); + + expect(pinData.pinnedDataByNodeName.value.Target).toBeUndefined(); + }); + + it('leaves unrelated pinned data untouched', () => { + const { pinData, fire } = createPinDataWithNodesChange(); + pinData.pinNodeData('Target', [{ json: { x: 1 } }] as INodeExecutionData[]); + pinData.pinNodeData('Other', [{ json: { y: 2 } }] as INodeExecutionData[]); + + fire({ action: CHANGE_ACTION.DELETE, payload: { id: 'node-id-1', name: 'Target' } }); + + expect(pinData.pinnedDataByNodeName.value.Target).toBeUndefined(); + expect(pinData.pinnedDataByNodeName.value.Other).toBeDefined(); + }); + + it('does nothing when DELETE has no node name (removeAllNodes)', () => { + const { pinData, fire } = createPinDataWithNodesChange(); + pinData.pinNodeData('Target', [{ json: { x: 1 } }] as INodeExecutionData[]); + + fire({ action: CHANGE_ACTION.DELETE, payload: {} }); + + // `removeAllNodes` clears pin data via the store-level `removeAllNodes` + // orchestration, not via this subscriber β€” so this individual subscription + // is a no-op for the bulk case. + expect(pinData.pinnedDataByNodeName.value.Target).toBeDefined(); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentPinData.ts b/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentPinData.ts index b668a6ceeaf..60635df91ef 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentPinData.ts +++ b/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentPinData.ts @@ -1,9 +1,24 @@ -import { ref, readonly } from 'vue'; +import { + computed, + effectScope, + ref, + readonly, + shallowReactive, + type ComputedRef, + type ShallowRef, +} from 'vue'; import { createEventHook } from '@vueuse/core'; import type { INodeExecutionData, IDataObject, IPinData } from 'n8n-workflow'; +import type { INodeUi } from '@/Interface'; import { isJsonKeyObject, stringSizeInBytes } from '@/app/utils/typesUtils'; import { CHANGE_ACTION } from './types'; import type { ChangeAction, ChangeEvent } from './types'; +import type { + NodeAddedPayload, + NodeRemovedPayload, + NodesChangeEvent, + NodesSetPayload, +} from './useWorkflowDocumentNodes'; export type PinDataNodePayload = { nodeName: string; @@ -66,7 +81,12 @@ export function getPinDataSize( }, 0); } -export function useWorkflowDocumentPinData() { +export type WorkflowDocumentPinDataDeps = { + nodesById: ShallowRef>; + onNodesChange: (cb: (event: NodesChangeEvent) => void) => void; +}; + +export function useWorkflowDocumentPinData(deps: WorkflowDocumentPinDataDeps) { const pinnedDataByNodeName = ref({}); const onPinnedDataChange = createEventHook(); @@ -141,8 +161,88 @@ export function useWorkflowDocumentPinData() { return pinnedDataByNodeName.value[nodeName]; } + // Per-node-id pin-data lookup. See useWorkflowDocumentRenderData for the + // general shallowReactive + per-entry computed pattern. + // + // Note: deliberately uses plain `computed` (Object.is gate) rather than + // `structuralComputed(..., isEqual)`. Pin data can reach ~10 MB per + // workflow, and every mutation replaces the inner array reference β€” so an + // isEqual gate would deep-compare megabytes on every change *and* never + // short-circuit (the immutable update pattern means no mutation ever + // produces a structurally-identical fresh array). Reference identity is + // the right gate here. + const pinnedDataByNodeId = shallowReactive( + new Map>(), + ); + const scopes = new Map void>(); + + function computePinnedData(nodeId: string): INodeExecutionData[] | undefined { + const node = deps.nodesById.value.get(nodeId); + if (!node) return undefined; + return pinnedDataByNodeName.value[node.name]; + } + + function applyAddPinEntry(nodeId: string) { + if (scopes.has(nodeId)) return; + const scope = effectScope(); + scope.run(() => { + pinnedDataByNodeId.set( + nodeId, + computed(() => computePinnedData(nodeId)), + ); + }); + scopes.set(nodeId, () => scope.stop()); + } + + function applyRemovePinEntry(nodeId: string) { + scopes.get(nodeId)?.(); + scopes.delete(nodeId); + pinnedDataByNodeId.delete(nodeId); + } + + function applyReconcilePinEntries(nodeIds: string[]) { + const nextIds = new Set(nodeIds); + for (const oldId of scopes.keys()) { + if (!nextIds.has(oldId)) applyRemovePinEntry(oldId); + } + for (const id of nodeIds) applyAddPinEntry(id); + } + + deps.onNodesChange((event) => { + switch (event.action) { + case CHANGE_ACTION.ADD: { + const { node } = event.payload as NodeAddedPayload; + applyAddPinEntry(node.id); + break; + } + case CHANGE_ACTION.DELETE: { + const payload = event.payload as NodeRemovedPayload; + if (payload.id) { + applyRemovePinEntry(payload.id); + } else { + applyReconcilePinEntries([]); + } + // Orphan pin cleanup: removing a node also clears any pinned data + // keyed by its name. Previously this was a cross-cut owned by + // useWorkflowDocumentNodes via an injected `unpinNodeData` dep; + // pulling it into the pinData subscriber means nodes doesn't need + // to know about pinData at all (breaks the construction cycle). + if (payload.name) applyUnpin(payload.name); + break; + } + case CHANGE_ACTION.SET: { + const { nodeIds } = event.payload as NodesSetPayload; + applyReconcilePinEntries(nodeIds); + break; + } + } + }); + + applyReconcilePinEntries(Array.from(deps.nodesById.value.keys())); + return { pinnedDataByNodeName: readonly(pinnedDataByNodeName), + pinnedDataByNodeId, setPinData, pinNodeData, unpinNodeData, diff --git a/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentRenderData.test.ts b/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentRenderData.test.ts index bd10c8d93f1..b8428622ec4 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentRenderData.test.ts +++ b/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentRenderData.test.ts @@ -1,70 +1,493 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { shallowReactive, type ComputedRef } from 'vue'; +import { computed, effectScope } from 'vue'; import { setActivePinia, createPinia } from 'pinia'; -import type { IPinData } from 'n8n-workflow'; -import type { CanvasConnectionPort } from '@/features/workflows/canvas/canvas.types'; +import type { INode } from 'n8n-workflow'; +import type { INodeUi } from '@/Interface'; +import { + createTestNode, + createTestWorkflow, + createTestWorkflowExecutionResponse, +} from '@/__tests__/mocks'; +import { STICKY_NODE_TYPE } from '@/app/constants'; import { useWorkflowDocumentStore, createWorkflowDocumentId, + type WorkflowDocumentId, } from '@/app/stores/workflowDocument.store'; import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store'; +import { useExecutionDataStore, createExecutionDataId } from '@/app/stores/executionData.store'; import { useWorkflowDocumentRenderData } from './useWorkflowDocumentRenderData'; -const nodeInputsByNodeId = shallowReactive(new Map>()); -const nodeOutputsByNodeId = shallowReactive(new Map>()); -const pinnedDataByNodeName = shallowReactive({}); -const executionIssuesByNodeName = new Map>(); +const TEST_TRIGGER_NODE_TYPE = 'n8n-nodes-base.testTrigger'; -vi.mock('@/app/stores/workflowDocument.store', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useWorkflowDocumentStore: vi.fn(() => ({ - workflowId: 'wf-1', - nodeInputsByNodeId, - nodeOutputsByNodeId, - pinnedDataByNodeName, - })), - }; -}); +// External-surface mocks only β€” node-types and dirtiness pull in unrelated +// stores that aren't needed to exercise renderData's wiring. The actual +// workflowDocument / workflowExecutionState stores stay real so reconciliation +// off `onNodesChange` is genuinely tested. +vi.mock('@/app/composables/useNodeDirtiness', () => ({ + useNodeDirtiness: vi.fn(() => ({ dirtinessByName: computed(() => ({})) })), +})); -vi.mock('@/app/stores/workflowExecutionState.store', () => ({ - useWorkflowExecutionStateStore: vi.fn(() => ({ - activeExecutionIssuesByNodeName: executionIssuesByNodeName, +vi.mock('@/app/stores/nodeTypes.store', () => ({ + useNodeTypesStore: vi.fn(() => ({ + isConfigNode: () => false, + isConfigurableNode: () => false, + isTriggerNode: (type: string) => type === 'n8n-nodes-base.testTrigger', + getNodeType: (type: string) => + type === 'n8n-nodes-base.testTrigger' + ? { + name: 'n8n-nodes-base.testTrigger', + displayName: 'Test Trigger', + description: '', + version: 1, + defaults: {}, + group: ['trigger'], + inputs: [], + outputs: ['main'], + properties: [], + } + : null, + communityNodeType: () => undefined, + getAllNodeTypes: () => [], })), })); -describe('useWorkflowDocumentRenderData', () => { +function setupWorkflow(id: string, nodes: Array> = []) { + const docId = createWorkflowDocumentId(id, 'v1'); + const doc = useWorkflowDocumentStore(docId); + doc.setNodes( + nodes.map((n) => + createTestNode({ + id: n.id ?? `${n.name}-id`, + name: n.name ?? 'Node', + type: n.type ?? 'test', + ...n, + } as Partial), + ), + ); + return { docId, doc }; +} + +/** + * Loads an execution into a per-execution data store (with a workflowData + * snapshot providing the id↔name mapping) and marks it active on the + * document's execution state store, so the active-execution by-id projections + * resolve through it. + */ +function setActiveExecution( + docId: WorkflowDocumentId, + nodes: INode[], + runData: Record>>, +) { + const executionId = `exec-${docId}`; + const executionDataStore = useExecutionDataStore(createExecutionDataId(executionId)); + executionDataStore.setExecution( + createTestWorkflowExecutionResponse({ + id: executionId, + finished: false, + status: 'running', + workflowData: createTestWorkflow({ nodes }), + data: { resultData: { runData, lastNodeExecuted: '' } } as never, + }), + ); + useWorkflowExecutionStateStore(docId).setActiveExecutionId(executionId); +} + +// The composable is side-effectful and registers `onScopeDispose`, so it must +// run inside an `effectScope` (as it does in production). The returned `scope` +// lets a test exercise teardown via `scope.stop()`. +function createRenderData(docId: WorkflowDocumentId) { + const scope = effectScope(); + const renderData = scope.run(() => useWorkflowDocumentRenderData(docId))!; + return { renderData, scope }; +} + +describe('useWorkflowDocumentRenderData β€” passthroughs', () => { beforeEach(() => { setActivePinia(createPinia()); - vi.mocked(useWorkflowDocumentStore).mockClear(); - vi.mocked(useWorkflowExecutionStateStore).mockClear(); }); - it('passes through nodeInputsByNodeId and nodeOutputsByNodeId by reference', () => { - const renderData = useWorkflowDocumentRenderData(createWorkflowDocumentId('wf-1')); + it('re-exposes workflowDocument by-id projections by identity', () => { + const { docId, doc } = setupWorkflow('wf-pass', [{ id: 'a', name: 'Alpha' }]); + const { renderData } = createRenderData(docId); - expect(renderData.nodeInputsByNodeId).toBe(nodeInputsByNodeId); - expect(renderData.nodeOutputsByNodeId).toBe(nodeOutputsByNodeId); + expect(renderData.nodeInputsByNodeId).toBe(doc.nodeInputsByNodeId); + expect(renderData.nodeOutputsByNodeId).toBe(doc.nodeOutputsByNodeId); + expect(renderData.pinnedDataByNodeName).toBe(doc.pinnedDataByNodeName); + expect(renderData.pinnedDataByNodeId).toBe(doc.pinnedDataByNodeId); + expect(renderData.validationErrorsByNodeId).toBe(doc.validationErrorsByNodeId); }); - it('passes through pinnedDataByNodeName by reference', () => { - const renderData = useWorkflowDocumentRenderData(createWorkflowDocumentId('wf-1')); + it('owns per-node-id node-type derivation maps locally', () => { + const { docId } = setupWorkflow('wf-nodetype-info', [{ id: 'a', name: 'Alpha' }]); + const { renderData } = createRenderData(docId); - expect(renderData.pinnedDataByNodeName).toBe(pinnedDataByNodeName); + expect(renderData.nodeTypeDescriptionByNodeId.has('a')).toBe(true); + expect(renderData.isTriggerByNodeId.has('a')).toBe(true); + expect(renderData.subtitleByNodeId.has('a')).toBe(true); + expect(renderData.simulatedNodeTypeDescriptionByNodeId.has('a')).toBe(true); }); +}); - it('exposes executionIssuesByNodeName resolved via the workflow execution state store', () => { - const renderData = useWorkflowDocumentRenderData(createWorkflowDocumentId('wf-1')); +describe('useWorkflowDocumentRenderData β€” fusion projections', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('initializes fusion maps for nodes present at construction time', () => { + const { docId } = setupWorkflow('wf-fusion-init', [ + { id: 'a', name: 'Alpha' }, + { id: 'b', name: 'Beta' }, + ]); + const { renderData } = createRenderData(docId); + + expect(renderData.tooltipByNodeId.has('a')).toBe(true); + expect(renderData.tooltipByNodeId.has('b')).toBe(true); + expect(renderData.hasIssuesByNodeId.has('a')).toBe(true); + expect(renderData.hasIssuesByNodeId.has('b')).toBe(true); + expect(renderData.renderTypeByNodeId.has('a')).toBe(true); + expect(renderData.renderTypeByNodeId.has('b')).toBe(true); + }); + + it('reconciles fusion maps when a node is added', () => { + const { docId, doc } = setupWorkflow('wf-fusion-add', [{ id: 'a', name: 'Alpha' }]); + const { renderData } = createRenderData(docId); + + doc.addNode(createTestNode({ id: 'b', name: 'Beta', type: 'test' })); + + expect(renderData.tooltipByNodeId.has('b')).toBe(true); + expect(renderData.hasIssuesByNodeId.has('b')).toBe(true); + expect(renderData.renderTypeByNodeId.has('b')).toBe(true); + }); + + it('reconciles fusion maps when a node is removed', () => { + const { docId, doc } = setupWorkflow('wf-fusion-remove', [ + { id: 'a', name: 'Alpha' }, + { id: 'b', name: 'Beta' }, + ]); + const { renderData } = createRenderData(docId); + + doc.removeNodeById('a'); + + expect(renderData.tooltipByNodeId.has('a')).toBe(false); + expect(renderData.hasIssuesByNodeId.has('a')).toBe(false); + expect(renderData.renderTypeByNodeId.has('a')).toBe(false); + expect(renderData.tooltipByNodeId.has('b')).toBe(true); + }); - expect(renderData.executionIssuesByNodeName).toBe(executionIssuesByNodeName); + it('keeps hasIssuesByNodeId false for clean nodes with no execution data', () => { + const { docId } = setupWorkflow('wf-fusion-clean', [{ id: 'a', name: 'Alpha' }]); + const { renderData } = createRenderData(docId); + + expect(renderData.hasIssuesByNodeId.get('a')?.value).toBe(false); + }); + + it('returns undefined tooltip for non-trigger nodes', () => { + const { docId } = setupWorkflow('wf-fusion-tooltip', [{ id: 'a', name: 'Alpha' }]); + const { renderData } = createRenderData(docId); + + expect(renderData.tooltipByNodeId.get('a')?.value).toBeUndefined(); + }); + + it('returns the default render type for a generic node', () => { + const { docId } = setupWorkflow('wf-fusion-default', [{ id: 'a', name: 'Alpha' }]); + const { renderData } = createRenderData(docId); + + const render = renderData.renderTypeByNodeId.get('a')?.value; + expect(render?.type).toBe('default'); + }); + + it('returns a sticky-note render type for sticky nodes', () => { + const { docId } = setupWorkflow('wf-fusion-sticky', [ + { id: 's', name: 'Sticky', type: 'n8n-nodes-base.stickyNote' }, + ]); + const { renderData } = createRenderData(docId); + + const render = renderData.renderTypeByNodeId.get('s')?.value; + expect(render?.type).toBe('n8n-nodes-base.stickyNote'); }); + + it('assigns z-index entries only for sticky notes via additionalPropertiesByNodeId', () => { + const { docId } = setupWorkflow('wf-fusion-additional', [ + { id: 's1', name: 'Sticky1', type: 'n8n-nodes-base.stickyNote' }, + { id: 'n1', name: 'Node1', type: 'test' }, + ]); + const { renderData } = createRenderData(docId); + + const props = renderData.additionalPropertiesByNodeId.value; + expect(props.s1).toBeDefined(); + expect(props.n1).toBeUndefined(); + }); +}); + +describe('useWorkflowDocumentRenderData β€” hasIssuesByNodeId precedence', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + const alpha = () => createTestNode({ id: 'a', name: 'Alpha' }); + + it('reports issues when the execution status is crashed', () => { + const { docId } = setupWorkflow('wf-issues-crashed', [{ id: 'a', name: 'Alpha' }]); + setActiveExecution(docId, [alpha()], { Alpha: [{ executionStatus: 'crashed' }] }); + const { renderData } = createRenderData(docId); + + expect(renderData.hasIssuesByNodeId.get('a')?.value).toBe(true); + }); + + it('reports issues when the execution status is error', () => { + const { docId } = setupWorkflow('wf-issues-error', [{ id: 'a', name: 'Alpha' }]); + setActiveExecution(docId, [alpha()], { Alpha: [{ executionStatus: 'error' }] }); + const { renderData } = createRenderData(docId); + + expect(renderData.hasIssuesByNodeId.get('a')?.value).toBe(true); + }); + + it('reports no issues when the node has pinned data despite validation issues', () => { + const { docId, doc } = setupWorkflow('wf-issues-pinned', [ + { id: 'a', name: 'Alpha', issues: { typeUnknown: true } }, + ]); + doc.pinNodeData('Alpha', [{ json: {} }]); + const { renderData } = createRenderData(docId); + + // The node genuinely has validation errors β€” pinned data must still win. + expect(renderData.validationErrorsByNodeId.get('a')?.value?.length).toBeGreaterThan(0); + expect(renderData.hasIssuesByNodeId.get('a')?.value).toBe(false); + }); + + it('reports issues for validation errors when there is no pinned data', () => { + const { docId } = setupWorkflow('wf-issues-validation', [ + { id: 'a', name: 'Alpha', issues: { typeUnknown: true } }, + ]); + const { renderData } = createRenderData(docId); + + expect(renderData.hasIssuesByNodeId.get('a')?.value).toBe(true); + }); + + it('reports issues when the active execution reports execution errors', () => { + const { docId } = setupWorkflow('wf-issues-execution', [{ id: 'a', name: 'Alpha' }]); + setActiveExecution(docId, [alpha()], { + Alpha: [{ executionStatus: 'error', error: { message: 'boom' } }], + }); + const { renderData } = createRenderData(docId); + + expect(renderData.hasIssuesByNodeId.get('a')?.value).toBe(true); + }); + + it('reports no issues when a successful run follows an errored one', () => { + const { docId } = setupWorkflow('wf-issues-retry', [{ id: 'a', name: 'Alpha' }]); + setActiveExecution(docId, [alpha()], { + Alpha: [ + { executionStatus: 'error', error: { message: 'boom' } }, + { executionStatus: 'success' }, + ], + }); + const { renderData } = createRenderData(docId); + + expect(renderData.hasIssuesByNodeId.get('a')?.value).toBe(false); + }); +}); + +describe('useWorkflowDocumentRenderData β€” trigger tooltip', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + function setupRunningWorkflow(id: string, nodes: Array>) { + const result = setupWorkflow(id, nodes); + // `null` marks an execution as started with its id still pending, which + // flips `isWorkflowRunning` without needing execution data. + useWorkflowExecutionStateStore(result.docId).setActiveExecutionId(null); + return result; + } + + it('shows the waiting tooltip on the single active trigger while the workflow is running', () => { + const { docId } = setupRunningWorkflow('wf-tooltip-shown', [ + { id: 't', name: 'Trigger', type: TEST_TRIGGER_NODE_TYPE }, + { id: 'a', name: 'Alpha' }, + ]); + const { renderData } = createRenderData(docId); + + expect(renderData.tooltipByNodeId.get('t')?.value).toBe( + 'Waiting for you to create an event in Test', + ); + }); + + it('does not show a tooltip when the trigger has pinned data', () => { + const { docId, doc } = setupRunningWorkflow('wf-tooltip-pinned', [ + { id: 't', name: 'Trigger', type: TEST_TRIGGER_NODE_TYPE }, + ]); + doc.pinNodeData('Trigger', [{ json: {} }]); + const { renderData } = createRenderData(docId); + + expect(renderData.tooltipByNodeId.get('t')?.value).toBeUndefined(); + }); + + it('does not show a tooltip when the workflow is not running', () => { + const { docId } = setupWorkflow('wf-tooltip-idle', [ + { id: 't', name: 'Trigger', type: TEST_TRIGGER_NODE_TYPE }, + ]); + const { renderData } = createRenderData(docId); + + expect(renderData.tooltipByNodeId.get('t')?.value).toBeUndefined(); + }); + + it('does not show a tooltip on disabled triggers', () => { + const { docId } = setupRunningWorkflow('wf-tooltip-disabled', [ + { id: 't', name: 'Trigger', type: TEST_TRIGGER_NODE_TYPE, disabled: true }, + ]); + const { renderData } = createRenderData(docId); + + expect(renderData.tooltipByNodeId.get('t')?.value).toBeUndefined(); + }); + + it('suppresses tooltips when multiple triggers are active and the driving trigger is unknown', () => { + const { docId } = setupRunningWorkflow('wf-tooltip-multi', [ + { id: 't1', name: 'Trigger 1', type: TEST_TRIGGER_NODE_TYPE }, + { id: 't2', name: 'Trigger 2', type: TEST_TRIGGER_NODE_TYPE }, + ]); + const { renderData } = createRenderData(docId); + + expect(renderData.tooltipByNodeId.get('t1')?.value).toBeUndefined(); + expect(renderData.tooltipByNodeId.get('t2')?.value).toBeUndefined(); + }); +}); + +describe('useWorkflowDocumentRenderData β€” sticky z-index ordering', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + // Grid-aligned positions so the document store's snap-to-grid is a no-op + // and the overlap geometry below is exact. + function sticky(id: string, position: [number, number], size: number): Partial { + return { + id, + name: `Sticky ${id}`, + type: STICKY_NODE_TYPE, + position, + parameters: { width: size, height: size }, + }; + } + + it('assigns the base z-index to a single sticky', () => { + const { docId } = setupWorkflow('wf-sticky-single', [sticky('s1', [0, 0], 100)]); + const { renderData } = createRenderData(docId); + + expect(renderData.additionalPropertiesByNodeId.value.s1).toEqual({ + style: { zIndex: -100 }, + }); + }); + + it('assigns sequential z-indexes to non-overlapping stickies', () => { + const { docId } = setupWorkflow('wf-sticky-apart', [ + sticky('s1', [0, 0], 100), + sticky('s2', [320, 320], 100), + ]); + const { renderData } = createRenderData(docId); + + const props = renderData.additionalPropertiesByNodeId.value; + expect(props.s1).toEqual({ style: { zIndex: -100 } }); + expect(props.s2).toEqual({ style: { zIndex: -99 } }); + }); + + it('raises a smaller sticky above a larger overlapping one', () => { + const { docId } = setupWorkflow('wf-sticky-overlap', [ + sticky('small', [48, 48], 100), + sticky('large', [0, 0], 160), + ]); + const { renderData } = createRenderData(docId); + + const props = renderData.additionalPropertiesByNodeId.value; + expect(props.large).toEqual({ style: { zIndex: -100 } }); + expect(props.small).toEqual({ style: { zIndex: -99 } }); + }); + + it('orders multiple overlapping stickies by area with smaller ones on top', () => { + const { docId } = setupWorkflow('wf-sticky-multi', [ + sticky('s1', [0, 0], 100), + sticky('s2', [32, 32], 50), + sticky('s3', [48, 48], 100), + ]); + const { renderData } = createRenderData(docId); + + const props = renderData.additionalPropertiesByNodeId.value; + expect(props.s1).toEqual({ style: { zIndex: -100 } }); + expect(props.s2).toEqual({ style: { zIndex: -98 } }); + expect(props.s3).toEqual({ style: { zIndex: -99 } }); + }); +}); + +describe('useWorkflowDocumentRenderData β€” lifecycle', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('constructs without inject() warnings outside a component context', () => { + // Production constructs this composable in detached effect scopes (watch + // callbacks in WorkflowCanvas.vue / useWorkflowDiff.ts), where Vue's + // inject() is unavailable. Nothing in the construction path may inject. + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const { docId } = setupWorkflow('wf-inject', [{ id: 'a', name: 'Alpha' }]); + createRenderData(docId); + const warnings = warnSpy.mock.calls.flat().filter((arg) => typeof arg === 'string'); + expect(warnings.join('\n')).not.toContain('inject() can only be used'); + } finally { + warnSpy.mockRestore(); + } + }); + + it('stops reconciling once its scope is disposed', () => { + const { docId, doc } = setupWorkflow('wf-teardown', [{ id: 'a', name: 'Alpha' }]); + const { renderData, scope } = createRenderData(docId); + + expect(renderData.renderTypeByNodeId.has('a')).toBe(true); + + scope.stop(); + doc.addNode(createTestNode({ id: 'b', name: 'Beta', type: 'test' })); + + // The `onNodesChange` subscription was removed on teardown, so the node + // added after disposal is not reconciled into the maps. + expect(renderData.renderTypeByNodeId.has('b')).toBe(false); + expect(renderData.tooltipByNodeId.has('b')).toBe(false); + expect(renderData.hasIssuesByNodeId.has('b')).toBe(false); + }); + + it('exposes active-execution projections as live getters, not captured snapshots', () => { + const { docId } = setupWorkflow('wf-fresh', [{ id: 'a', name: 'Alpha' }]); + const { renderData } = createRenderData(docId); + const executionStateStore = useWorkflowExecutionStateStore(docId); + + // With no execution, the getter resolves to the store's current map. + const emptyMap = renderData.executionStatusByNodeId; + expect(emptyMap).toBe(executionStateStore.activeExecutionStatusByNodeId); + + // Swapping the resolved execution changes the source map identity; the + // getter must reflect the new map rather than a value captured at setup. + executionStateStore.setActiveExecutionId('exec-fresh'); + expect(renderData.executionStatusByNodeId).toBe( + executionStateStore.activeExecutionStatusByNodeId, + ); + expect(renderData.executionStatusByNodeId).not.toBe(emptyMap); + }); + + it('exposes pinnedDataByNodeName as a live getter that computeds track across pin/unpin', () => { + const { docId, doc } = setupWorkflow('wf-pin-live', [{ id: 'a', name: 'Alpha' }]); + const { renderData } = createRenderData(docId); - it('uses the exact workflow document id when resolving execution state', () => { - const documentId = createWorkflowDocumentId('wf-1', 'ver-123'); + // Mirrors the canvas pin-badge computed in CanvasNodeStatusIcons / + // CanvasNodeDefault: `!!renderData.value.pinnedDataByNodeName[name]`. + const hasPinnedData = computed(() => !!renderData.pinnedDataByNodeName.Alpha); + expect(hasPinnedData.value).toBe(false); - useWorkflowDocumentRenderData(documentId); + // Pin mutations replace the store ref's value; a snapshot captured at + // setup would keep returning the pre-pin object and the computed would + // never invalidate. + doc.pinNodeData('Alpha', [{ json: { value: 1 } }]); + expect(hasPinnedData.value).toBe(true); - expect(useWorkflowExecutionStateStore).toHaveBeenCalledWith(documentId); + doc.unpinNodeData('Alpha'); + expect(hasPinnedData.value).toBe(false); }); }); diff --git a/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentRenderData.ts b/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentRenderData.ts index c9394679dab..c5e1f52ca78 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentRenderData.ts +++ b/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentRenderData.ts @@ -1,33 +1,522 @@ +import { computed, effectScope, onScopeDispose, shallowReactive, type ComputedRef } from 'vue'; +import isEqual from 'lodash/isEqual'; +import { structuralComputed } from '@n8n/composables/structuralComputed'; +import { useI18n } from '@n8n/i18n'; +import type { INodeTypeDescription } from 'n8n-workflow'; import { useWorkflowDocumentStore, type WorkflowDocumentId, } from '@/app/stores/workflowDocument.store'; import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store'; +import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; +import { useNodeDirtiness } from '@/app/composables/useNodeDirtiness'; +import { getNodeIconSource } from '@/app/utils/nodeIcon'; +import { getNodeSubtitle, getTriggerNodeServiceName } from '@/app/utils/nodeTypesUtils'; +import { + CUSTOM_API_CALL_KEY, + SIMULATE_NODE_TYPE, + SIMULATE_TRIGGER_NODE_TYPE, + STICKY_NODE_TYPE, +} from '@/app/constants'; +import type { INodeUi } from '@/Interface'; +import { checkOverlap } from '@/features/workflows/canvas/canvas.utils'; +import type { + BoundingBox, + CanvasNode, + CanvasNodeAddNodesRender, + CanvasNodeChoicePromptRender, + CanvasNodeData, + CanvasNodeDefaultRender, + CanvasNodeStickyNoteRender, +} from '@/features/workflows/canvas/canvas.types'; +import { CanvasNodeRenderType } from '@/features/workflows/canvas/canvas.types'; +import { CHANGE_ACTION } from './types'; +import type { + NodeAddedPayload, + NodeRemovedPayload, + NodesChangeEvent, + NodesSetPayload, +} from './useWorkflowDocumentNodes'; /** * Canvas render data accessor for a workflow document. * - * Thin faΓ§ade that re-exposes: - * - the per-node port maps (`nodeInputsByNodeId`, `nodeOutputsByNodeId`) - * owned by `useWorkflowDocumentNodes`, - * - the pin data map (`pinnedDataByNodeName`) owned by - * `useWorkflowDocumentPinData`, and - * - the active execution's `executionIssuesByNodeName` map. + * Provides a single object combining: + * - by-node-id passthroughs from `workflowDocument` (port maps, pin data, + * validation errors) and `workflowExecutionState` (status / run data / + * output map / waiting / running / waiting-for-next / issues β€” each with + * active/displayed-execution fallback); + * - per-node-id derivations from the workflow document + * (`nodeTypeDescriptionByNodeId`, `isTriggerByNodeId`, `subtitleByNodeId`, + * `simulatedNodeTypeDescriptionByNodeId`); + * - **fusion projections** that combine state from both stores into the + * canvas-shaped outputs the renderer consumes: `tooltipByNodeId`, + * `hasIssuesByNodeId`, `additionalPropertiesByNodeId`, + * `renderTypeByNodeId`. * - * Reactivity is owned by the underlying stores: the port maps are stable - * references on the workflow document store, `pinnedDataByNodeName` is a - * `shallowReactive` with per-key reactivity, and `executionIssuesByNodeName` - * resolves through a `computed` on the workflow execution state store that - * swaps between the active and displayed execution's per-execution maps. + * Per-entry maps use a `shallowReactive>>` pattern: + * each entry runs in its own `effectScope` and `structuralComputed` gates + * downstream propagation. Entry lifecycle is driven by the document store's + * `onNodesChange` event, so add/remove are O(1) and updates re-evaluate + * lazily. + * + * Lifecycle: this is side-effectful (subscribes to `onNodesChange`, creates + * per-node scopes), so it must be invoked **once per document id inside an + * `effectScope` the caller owns** β€” never inside a re-evaluating `computed`. + * Stopping that scope runs the `onScopeDispose` teardown below. See + * `WorkflowCanvas.vue` and `useWorkflowDiff.ts` for the call pattern. */ export function useWorkflowDocumentRenderData(workflowDocumentId: WorkflowDocumentId) { const workflowDocumentStore = useWorkflowDocumentStore(workflowDocumentId); const executionStateStore = useWorkflowExecutionStateStore(workflowDocumentId); + const nodeTypesStore = useNodeTypesStore(); + const i18n = useI18n(); + const { dirtinessByName } = useNodeDirtiness(workflowDocumentId); + + // ------------------------------------------------------------------------- + // Per-entry maps reconciled off the document store's `onNodesChange`. + // ------------------------------------------------------------------------- + + const nodeTypeDescriptionByNodeId = shallowReactive( + new Map>(), + ); + const isTriggerByNodeId = shallowReactive(new Map>()); + const subtitleByNodeId = shallowReactive(new Map>()); + const simulatedNodeTypeDescriptionByNodeId = shallowReactive( + new Map>(), + ); + const tooltipByNodeId = shallowReactive(new Map>()); + const hasIssuesByNodeId = shallowReactive(new Map>()); + const renderTypeByNodeId = shallowReactive( + new Map>(), + ); + const entryScopes = new Map void>(); + + function getNode(nodeId: string): INodeUi | undefined { + return workflowDocumentStore.nodesById.get(nodeId); + } + + // --- nodeTypeDescriptionByNodeId / isTriggerByNodeId / subtitleByNodeId / simulated --- + + function computeNodeTypeDescription(nodeId: string): INodeTypeDescription | null { + const node = getNode(nodeId); + if (!node) return null; + return ( + nodeTypesStore.getNodeType(node.type, node.typeVersion) ?? + nodeTypesStore.communityNodeType(node.type)?.nodeDescription ?? + null + ); + } + + function computeIsTrigger(nodeId: string): boolean { + const node = getNode(nodeId); + if (!node) return false; + return nodeTypesStore.isTriggerNode(node.type); + } + + function computeSubtitle(nodeId: string): string { + const node = getNode(nodeId); + if (!node) return ''; + try { + const nodeTypeDescription = computeNodeTypeDescription(nodeId); + if (!nodeTypeDescription) return ''; + + const subtitle = getNodeSubtitle( + node, + nodeTypeDescription, + workflowDocumentStore.getWorkflowObjectAccessorSnapshot(), + ); + if (subtitle === undefined) return ''; + // Subtitles that resolve to the "custom API call" placeholder are noise β€” hide them. + if (subtitle.includes(CUSTOM_API_CALL_KEY)) return ''; + return subtitle; + } catch { + return ''; + } + } + + function computeSimulatedNodeTypeDescription(nodeId: string): INodeTypeDescription | null { + const node = getNode(nodeId); + if (!node) return null; + if (node.type !== SIMULATE_NODE_TYPE && node.type !== SIMULATE_TRIGGER_NODE_TYPE) return null; + + const icon = node.parameters?.icon as string; + const iconValue = workflowDocumentStore + .getExpressionHandler() + .getSimpleParameterValue(node, icon, 'internal', {}); + if (iconValue && typeof iconValue === 'string') { + return nodeTypesStore.getNodeType(iconValue) ?? null; + } + return null; + } + + // --- tooltipByNodeId ----------------------------------------------------- + // Original: useCanvasMapping `nodeTooltipById`. Surfaces a "waiting for + // trigger event" tooltip on the active trigger node while a workflow is + // running. Hides itself when more than one trigger is active and the + // driving trigger isn't identified yet, when the node is disabled, when + // it has pinned data, or when it has already produced/errored output. + function computeTooltip(nodeId: string): string | undefined { + if (!executionStateStore.isWorkflowRunning) return undefined; + const node = getNode(nodeId); + if (!node) return undefined; + + const isTrigger = isTriggerByNodeId.get(nodeId)?.value ?? false; + const nodeTypeDescription = nodeTypeDescriptionByNodeId.get(nodeId)?.value ?? null; + if (!isTrigger || !nodeTypeDescription) return undefined; + + const triggerNodeName = executionStateStore.activeExecution?.triggerNode; + // Count active (non-disabled) triggers; when ambiguous and the driving + // trigger isn't named, suppress all tooltips. + if (triggerNodeName === undefined) { + let activeTriggerCount = 0; + for (const [id] of isTriggerByNodeId) { + const n = workflowDocumentStore.nodesById.get(id); + if (!n) continue; + if (isTriggerByNodeId.get(id)?.value && !n.disabled) { + activeTriggerCount += 1; + } + } + if (activeTriggerCount !== 1) return undefined; + } else if (triggerNodeName !== node.name) { + return undefined; + } + + if (node.disabled) return undefined; + const status = executionStateStore.activeExecutionStatusByNodeId.get(nodeId)?.value ?? 'new'; + if (!['new', 'unknown', 'waiting'].includes(status)) return undefined; + const pinned = workflowDocumentStore.pinnedDataByNodeId.get(nodeId)?.value; + if (pinned) return undefined; + + if (typeof nodeTypeDescription.eventTriggerDescription === 'string') { + const nodeName = i18n.shortNodeType(nodeTypeDescription.name); + return i18n + .nodeText(nodeTypeDescription.name) + .eventTriggerDescription(nodeName, nodeTypeDescription.eventTriggerDescription ?? ''); + } + return i18n.baseText('node.waitingForYouToCreateAnEventIn', { + interpolate: { + nodeType: getTriggerNodeServiceName(nodeTypeDescription), + }, + }); + } + + // --- hasIssuesByNodeId --------------------------------------------------- + // Original: useCanvasMapping `nodeHasIssuesById`. Decides whether the node + // renders with the "has issues" affordance. Hard-error execution statuses + // (crashed/error) win first β€” a pinned node that crashed still shows issues. + // Otherwise pinned data clears issues (false), then validation errors, the + // execution-issue map, and finally a last-task `.error` check. + function computeHasIssues(nodeId: string): boolean { + const node = getNode(nodeId); + if (!node) return false; + + const status = executionStateStore.activeExecutionStatusByNodeId.get(nodeId)?.value ?? 'new'; + if (status === 'crashed' || status === 'error') return true; + + const pinned = workflowDocumentStore.pinnedDataByNodeId.get(nodeId)?.value; + if (pinned) return false; + + const validationErrors = + workflowDocumentStore.validationErrorsByNodeId.get(nodeId)?.value ?? []; + if (validationErrors.length > 0) return true; + + const executionIssues = + executionStateStore.activeExecutionIssuesByNodeName.get(node.name)?.value ?? []; + if (executionIssues.length > 0) return true; + + const tasks = executionStateStore.activeExecutionRunDataByNodeId.get(nodeId)?.value ?? null; + return Boolean(tasks?.at(-1)?.error); + } + + // --- renderTypeByNodeId -------------------------------------------------- + // Original: useCanvasMapping `renderTypeByNodeId` + create*RenderType + // helpers. Returns the canvas render type/options the renderer reads. + 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 createChoicePromptRenderType(): CanvasNodeChoicePromptRender { + return { type: CanvasNodeRenderType.ChoicePrompt, options: {} }; + } + + function createDefaultNodeRenderType(node: INodeUi): CanvasNodeDefaultRender { + const nodeType = nodeTypeDescriptionByNodeId.get(node.id)?.value ?? null; + const simulated = simulatedNodeTypeDescriptionByNodeId.get(node.id)?.value ?? null; + const iconSource = simulated ?? nodeType ?? node.type; + const icon = getNodeIconSource(iconSource, node, workflowDocumentStore.getExpressionHandler()); + + const isTrigger = isTriggerByNodeId.get(node.id)?.value ?? false; + const tooltip = tooltipByNodeId.get(node.id)?.value; + + // Snapshot the workflow object accessors per-call. Reads inside the + // snapshot factory access the underlying stores' refs, so the + // surrounding structuralComputed picks up dependencies and re-evaluates + // when they change. + const workflowAccessors = workflowDocumentStore.getWorkflowObjectAccessorSnapshot(); + + return { + type: CanvasNodeRenderType.Default, + options: { + trigger: isTrigger, + configuration: nodeTypesStore.isConfigNode(workflowAccessors, node, node.type), + configurable: nodeTypesStore.isConfigurableNode( + workflowAccessors, + node, + node.type, + node.typeVersion, + ), + tooltip, + dirtiness: dirtinessByName.value[node.name], + icon, + placeholder: node.placeholder, + }, + }; + } + + function computeRenderType(nodeId: string): CanvasNodeData['render'] { + const node = getNode(nodeId); + if (!node) return { type: CanvasNodeRenderType.Default, options: {} }; + switch (node.type) { + case `${CanvasNodeRenderType.StickyNote}`: + return createStickyNoteRenderType(node); + case `${CanvasNodeRenderType.AddNodes}`: + return createAddNodesRenderType(); + case `${CanvasNodeRenderType.ChoicePrompt}`: + return createChoicePromptRenderType(); + default: + return createDefaultNodeRenderType(node); + } + } + + function applyAddEntry(nodeId: string) { + if (entryScopes.has(nodeId)) return; + const scope = effectScope(); + scope.run(() => { + nodeTypeDescriptionByNodeId.set( + nodeId, + structuralComputed(() => computeNodeTypeDescription(nodeId), isEqual), + ); + isTriggerByNodeId.set( + nodeId, + structuralComputed(() => computeIsTrigger(nodeId)), + ); + subtitleByNodeId.set( + nodeId, + structuralComputed(() => computeSubtitle(nodeId)), + ); + simulatedNodeTypeDescriptionByNodeId.set( + nodeId, + structuralComputed(() => computeSimulatedNodeTypeDescription(nodeId), isEqual), + ); + tooltipByNodeId.set( + nodeId, + structuralComputed(() => computeTooltip(nodeId)), + ); + hasIssuesByNodeId.set( + nodeId, + structuralComputed(() => computeHasIssues(nodeId)), + ); + renderTypeByNodeId.set( + nodeId, + structuralComputed(() => computeRenderType(nodeId), isEqual), + ); + }); + entryScopes.set(nodeId, () => scope.stop()); + } + + function applyRemoveEntry(nodeId: string) { + entryScopes.get(nodeId)?.(); + entryScopes.delete(nodeId); + nodeTypeDescriptionByNodeId.delete(nodeId); + isTriggerByNodeId.delete(nodeId); + subtitleByNodeId.delete(nodeId); + simulatedNodeTypeDescriptionByNodeId.delete(nodeId); + tooltipByNodeId.delete(nodeId); + hasIssuesByNodeId.delete(nodeId); + renderTypeByNodeId.delete(nodeId); + } + + function applyReconcileEntries(nodeIds: string[]) { + const next = new Set(nodeIds); + for (const old of entryScopes.keys()) { + if (!next.has(old)) applyRemoveEntry(old); + } + for (const id of nodeIds) applyAddEntry(id); + } + + let nodesChangeSubscription: { off: () => void } | undefined; + if (typeof workflowDocumentStore.onNodesChange === 'function') { + nodesChangeSubscription = workflowDocumentStore.onNodesChange((event: NodesChangeEvent) => { + switch (event.action) { + case CHANGE_ACTION.ADD: { + const { node } = event.payload as NodeAddedPayload; + applyAddEntry(node.id); + break; + } + case CHANGE_ACTION.DELETE: { + const payload = event.payload as NodeRemovedPayload; + if (payload.id) applyRemoveEntry(payload.id); + else applyReconcileEntries([]); + break; + } + case CHANGE_ACTION.SET: { + const { nodeIds } = event.payload as NodesSetPayload; + applyReconcileEntries(nodeIds); + break; + } + } + }); + } + + const initialIds = workflowDocumentStore.nodesById; + if (initialIds && typeof initialIds.keys === 'function') { + applyReconcileEntries(Array.from(initialIds.keys())); + } + + // Teardown. This composable is invoked once per document id inside a + // caller-owned `effectScope` (see WorkflowCanvas.vue / useWorkflowDiff.ts). + // When that scope stops β€” on document-id change or unmount β€” we remove the + // `onNodesChange` subscription (otherwise the document store's event hook + // keeps a closure referencing this instance's maps alive and keeps running + // reconciliation) and stop every per-entry scope. + onScopeDispose(() => { + nodesChangeSubscription?.off(); + for (const stop of entryScopes.values()) stop(); + entryScopes.clear(); + }); + + // ------------------------------------------------------------------------- + // `additionalPropertiesByNodeId` β€” sticky-note z-index overlap resolution. + // Whole-list computation (changing one sticky can ripple to others via + // overlap), so a single `computed` rather than per-entry maps. The result + // is keyed by node id; consumers read individual slots. + // ------------------------------------------------------------------------- + const additionalPropertiesByNodeId = computed(() => { + type StickyBox = BoundingBox & { id: string; area: number; zIndex: number }; + const stickyZIndexBase = -100; + + const stickyBoxes: StickyBox[] = []; + for (const node of workflowDocumentStore.allNodes) { + if (node.type !== STICKY_NODE_TYPE) continue; + const width = node.parameters.width as number; + const height = node.parameters.height as number; + stickyBoxes.push({ + id: node.id, + x: node.position[0], + y: node.position[1], + width, + height, + area: width * height, + zIndex: stickyZIndexBase, + }); + } + + // Larger area first; assign baseline z-indexes in that order. + stickyBoxes.sort((a, b) => b.area - a.area); + stickyBoxes.forEach((box, idx) => { + box.zIndex = stickyZIndexBase + idx; + }); + + // Bump smaller overlapping stickies above their larger neighbours so + // they remain interactive when stacked. + for (let i = 0; i < stickyBoxes.length; i++) { + for (let j = i + 1; j < stickyBoxes.length; j++) { + const a = stickyBoxes[i]; + const b = stickyBoxes[j]; + if (!checkOverlap(a, b)) continue; + if (a.area < b.area && a.zIndex <= b.zIndex) a.zIndex = b.zIndex + 1; + else if (b.area < a.area && b.zIndex <= a.zIndex) b.zIndex = a.zIndex + 1; + } + } + + const result: Record> = {}; + for (const box of stickyBoxes) { + result[box.id] = { style: { zIndex: box.zIndex } }; + } + return result; + }); return { - nodeInputsByNodeId: workflowDocumentStore.nodeInputsByNodeId, - nodeOutputsByNodeId: workflowDocumentStore.nodeOutputsByNodeId, - pinnedDataByNodeName: workflowDocumentStore.pinnedDataByNodeName, - executionIssuesByNodeName: executionStateStore.activeExecutionIssuesByNodeName, + // --- workflowDocument projections --- + // All exposed as getters, mirroring the execution-state block below. + // `pinnedDataByNodeName` requires it: pin mutations *replace* the + // underlying ref's value (immutable-update pattern), and this setup + // runs once per document id in an untracked scope β€” a direct capture + // would be a permanently stale snapshot. The getter re-resolves through + // the store on each access, so consumer computeds track the ref and + // invalidate on pin/unpin. The by-id maps are stable containers today, + // but getters keep the store passthroughs uniform and stay correct if + // any later becomes resolver-backed. + get nodeInputsByNodeId() { + return workflowDocumentStore.nodeInputsByNodeId; + }, + get nodeOutputsByNodeId() { + return workflowDocumentStore.nodeOutputsByNodeId; + }, + get pinnedDataByNodeName() { + return workflowDocumentStore.pinnedDataByNodeName; + }, + get pinnedDataByNodeId() { + return workflowDocumentStore.pinnedDataByNodeId; + }, + get validationErrorsByNodeId() { + return workflowDocumentStore.validationErrorsByNodeId; + }, + + // --- node-type derivations (inlined) --- + nodeTypeDescriptionByNodeId, + isTriggerByNodeId, + subtitleByNodeId, + simulatedNodeTypeDescriptionByNodeId, + + // --- workflowExecutionState projections --- + // All exposed as getters. Setup runs once per document id, but the + // `active*` maps swap identity when the active/displayed execution + // changes, so they must re-resolve on access to stay reactive. The + // running / waiting-for-next maps are stable today, but keeping every + // execution-state passthrough a getter is uniform and stays correct if + // any later becomes resolver-backed β€” and a getter over a stable ref + // behaves identically to capturing it. (Consumers read these inside + // their own computeds and none destructure them.) + get executionIssuesByNodeName() { + return executionStateStore.activeExecutionIssuesByNodeName; + }, + get executionStatusByNodeId() { + return executionStateStore.activeExecutionStatusByNodeId; + }, + get executionRunDataByNodeId() { + return executionStateStore.activeExecutionRunDataByNodeId; + }, + get executionRunDataOutputMapByNodeId() { + return executionStateStore.activeExecutionRunDataOutputMapByNodeId; + }, + get executionWaitingByNodeId() { + return executionStateStore.activeExecutionWaitingByNodeId; + }, + get executionRunningByNodeId() { + return executionStateStore.executionRunningByNodeId; + }, + get executionWaitingForNextByNodeId() { + return executionStateStore.executionWaitingForNextByNodeId; + }, + + // --- multi-store fusion projections --- + tooltipByNodeId, + hasIssuesByNodeId, + renderTypeByNodeId, + additionalPropertiesByNodeId, }; } diff --git a/packages/frontend/editor-ui/src/app/stores/workflowExecutionState.store.test.ts b/packages/frontend/editor-ui/src/app/stores/workflowExecutionState.store.test.ts index 5f913ac4dc9..ecb674c3405 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflowExecutionState.store.test.ts +++ b/packages/frontend/editor-ui/src/app/stores/workflowExecutionState.store.test.ts @@ -13,7 +13,10 @@ import { getWorkflowExecutionStateStoreId, disposeWorkflowExecutionStateStore, } from '@/app/stores/workflowExecutionState.store'; -import { createWorkflowDocumentId } from '@/app/stores/workflowDocument.store'; +import { + createWorkflowDocumentId, + useWorkflowDocumentStore, +} from '@/app/stores/workflowDocument.store'; import { useExecutionDataStore, createExecutionDataId } from '@/app/stores/executionData.store'; import { createTestTaskData, createTestWorkflowExecutionResponse } from '@/__tests__/mocks'; import type { IExecutionResponse } from '@/features/execution/executions/executions.types'; @@ -49,10 +52,10 @@ describe('workflowExecutionState.store', () => { describe('store identity', () => { it('uses a workflowId-scoped store id', () => { const id = createWorkflowDocumentId('wf-42'); - const workflowExecutionStateStore = useWorkflowExecutionStateStore(id); - expect(workflowExecutionStateStore.$id).toBe(getWorkflowExecutionStateStoreId(id)); - expect(workflowExecutionStateStore.documentId).toBe(id); - expect(workflowExecutionStateStore.workflowId).toBe('wf-42'); + const executionStateStore = useWorkflowExecutionStateStore(id); + expect(executionStateStore.$id).toBe(getWorkflowExecutionStateStoreId(id)); + expect(executionStateStore.documentId).toBe(id); + expect(executionStateStore.workflowId).toBe('wf-42'); }); it('different workflowIds produce isolated state stores', () => { @@ -71,63 +74,49 @@ describe('workflowExecutionState.store', () => { describe('activeExecutionId tri-state', () => { it('starts undefined', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - expect(workflowExecutionStateStore.activeExecutionId).toBeUndefined(); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + expect(executionStateStore.activeExecutionId).toBeUndefined(); }); it('null indicates execution started but id pending', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.setActiveExecutionId(null); - expect(workflowExecutionStateStore.activeExecutionId).toBeNull(); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.setActiveExecutionId(null); + expect(executionStateStore.activeExecutionId).toBeNull(); }); it('string indicates known execution id', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.setActiveExecutionId('exec-1'); - expect(workflowExecutionStateStore.activeExecutionId).toBe('exec-1'); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.setActiveExecutionId('exec-1'); + expect(executionStateStore.activeExecutionId).toBe('exec-1'); }); it('rolls activeExecutionId into previousExecutionId on transition to a new id', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.setActiveExecutionId('exec-1'); - workflowExecutionStateStore.setActiveExecutionId('exec-2'); - expect(workflowExecutionStateStore.previousExecutionId).toBe('exec-1'); - expect(workflowExecutionStateStore.activeExecutionId).toBe('exec-2'); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.setActiveExecutionId('exec-1'); + executionStateStore.setActiveExecutionId('exec-2'); + expect(executionStateStore.previousExecutionId).toBe('exec-1'); + expect(executionStateStore.activeExecutionId).toBe('exec-2'); }); it('does not update previousExecutionId when clearing to undefined', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.setActiveExecutionId('exec-1'); - workflowExecutionStateStore.setActiveExecutionId(undefined); - expect(workflowExecutionStateStore.previousExecutionId).toBeUndefined(); - expect(workflowExecutionStateStore.activeExecutionId).toBeUndefined(); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.setActiveExecutionId('exec-1'); + executionStateStore.setActiveExecutionId(undefined); + expect(executionStateStore.previousExecutionId).toBeUndefined(); + expect(executionStateStore.activeExecutionId).toBeUndefined(); }); it('setting a string id also sets displayedExecutionId', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.setActiveExecutionId('exec-1'); - expect(workflowExecutionStateStore.displayedExecutionId).toBe('exec-1'); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.setActiveExecutionId('exec-1'); + expect(executionStateStore.displayedExecutionId).toBe('exec-1'); }); it('clearing activeExecutionId preserves displayedExecutionId', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.setActiveExecutionId('exec-1'); - workflowExecutionStateStore.setActiveExecutionId(undefined); - expect(workflowExecutionStateStore.displayedExecutionId).toBe('exec-1'); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.setActiveExecutionId('exec-1'); + executionStateStore.setActiveExecutionId(undefined); + expect(executionStateStore.displayedExecutionId).toBe('exec-1'); }); }); @@ -354,46 +343,38 @@ describe('workflowExecutionState.store', () => { describe('activeExecution routing', () => { it('returns pendingExecution when activeExecutionId === null', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); const scaffold = makeExecution({ id: '__IN_PROGRESS__' }); - workflowExecutionStateStore.setPendingExecution(scaffold); - workflowExecutionStateStore.setActiveExecutionId(null); + executionStateStore.setPendingExecution(scaffold); + executionStateStore.setActiveExecutionId(null); - expect(workflowExecutionStateStore.activeExecution?.id).toBe('__IN_PROGRESS__'); + expect(executionStateStore.activeExecution?.id).toBe('__IN_PROGRESS__'); }); it('returns the executionData store entry when activeExecutionId is a string', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); const executionDataStore = useExecutionDataStore(createExecutionDataId('exec-1')); executionDataStore.setExecution(makeExecution({ id: 'exec-1' })); - workflowExecutionStateStore.setActiveExecutionId('exec-1'); + executionStateStore.setActiveExecutionId('exec-1'); - expect(workflowExecutionStateStore.activeExecution?.id).toBe('exec-1'); + expect(executionStateStore.activeExecution?.id).toBe('exec-1'); }); it('falls back to displayed execution after active is cleared', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); const executionDataStore = useExecutionDataStore(createExecutionDataId('exec-1')); executionDataStore.setExecution(makeExecution({ id: 'exec-1' })); - workflowExecutionStateStore.setActiveExecutionId('exec-1'); - workflowExecutionStateStore.setActiveExecutionId(undefined); + executionStateStore.setActiveExecutionId('exec-1'); + executionStateStore.setActiveExecutionId(undefined); - expect(workflowExecutionStateStore.activeExecution?.id).toBe('exec-1'); + expect(executionStateStore.activeExecution?.id).toBe('exec-1'); }); it('returns null when nothing is set', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - expect(workflowExecutionStateStore.activeExecution).toBeNull(); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + expect(executionStateStore.activeExecution).toBeNull(); }); }); @@ -404,7 +385,7 @@ describe('workflowExecutionState.store', () => { // timestamp on the targeted data store and nothing else. describe('resolver fallback', () => { it('routes string activeExecutionId β†’ that id', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( + const executionStateStore = useWorkflowExecutionStateStore( createWorkflowDocumentId('wf-1'), ); useExecutionDataStore(createExecutionDataId('exec-A')).setExecution( @@ -414,13 +395,13 @@ describe('workflowExecutionState.store', () => { createExecutionDataId('exec-A'), ).executionResultDataLastUpdate; - workflowExecutionStateStore.setActiveExecutionId('exec-A'); + executionStateStore.setActiveExecutionId('exec-A'); - expect(workflowExecutionStateStore.activeExecutionResultDataLastUpdate).toBe(expected); + expect(executionStateStore.activeExecutionResultDataLastUpdate).toBe(expected); }); it('routes null activeExecutionId β†’ IN_PROGRESS sentinel', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( + const executionStateStore = useWorkflowExecutionStateStore( createWorkflowDocumentId('wf-1'), ); useExecutionDataStore(createExecutionDataId(IN_PROGRESS_EXECUTION_ID)).setExecution( @@ -430,13 +411,13 @@ describe('workflowExecutionState.store', () => { createExecutionDataId(IN_PROGRESS_EXECUTION_ID), ).executionResultDataLastUpdate; - workflowExecutionStateStore.setActiveExecutionId(null); + executionStateStore.setActiveExecutionId(null); - expect(workflowExecutionStateStore.activeExecutionResultDataLastUpdate).toBe(expected); + expect(executionStateStore.activeExecutionResultDataLastUpdate).toBe(expected); }); it('routes undefined activeExecutionId with string displayedExecutionId β†’ displayed id', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( + const executionStateStore = useWorkflowExecutionStateStore( createWorkflowDocumentId('wf-1'), ); useExecutionDataStore(createExecutionDataId('exec-D')).setExecution( @@ -446,25 +427,25 @@ describe('workflowExecutionState.store', () => { createExecutionDataId('exec-D'), ).executionResultDataLastUpdate; - workflowExecutionStateStore.setActiveExecutionId('exec-D'); - workflowExecutionStateStore.setActiveExecutionId(undefined); + executionStateStore.setActiveExecutionId('exec-D'); + executionStateStore.setActiveExecutionId(undefined); - expect(workflowExecutionStateStore.activeExecutionId).toBeUndefined(); - expect(workflowExecutionStateStore.displayedExecutionId).toBe('exec-D'); - expect(workflowExecutionStateStore.activeExecutionResultDataLastUpdate).toBe(expected); + expect(executionStateStore.activeExecutionId).toBeUndefined(); + expect(executionStateStore.displayedExecutionId).toBe('exec-D'); + expect(executionStateStore.activeExecutionResultDataLastUpdate).toBe(expected); }); it('returns undefined when nothing is set', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( + const executionStateStore = useWorkflowExecutionStateStore( createWorkflowDocumentId('wf-1'), ); - expect(workflowExecutionStateStore.activeExecutionResultDataLastUpdate).toBeUndefined(); + expect(executionStateStore.activeExecutionResultDataLastUpdate).toBeUndefined(); }); }); describe('activeExecutionRunData', () => { it('proxies through the executionData store for the active id', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( + const executionStateStore = useWorkflowExecutionStateStore( createWorkflowDocumentId('wf-1'), ); useExecutionDataStore(createExecutionDataId('exec-1')).setExecution( @@ -473,13 +454,13 @@ describe('workflowExecutionState.store', () => { data: { resultData: { runData: { Trigger: [] } } } as never, }), ); - workflowExecutionStateStore.setActiveExecutionId('exec-1'); + executionStateStore.setActiveExecutionId('exec-1'); - expect(workflowExecutionStateStore.activeExecutionRunData).toEqual({ Trigger: [] }); + expect(executionStateStore.activeExecutionRunData).toEqual({ Trigger: [] }); }); it('falls back to IN_PROGRESS for null activeExecutionId', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( + const executionStateStore = useWorkflowExecutionStateStore( createWorkflowDocumentId('wf-1'), ); useExecutionDataStore(createExecutionDataId(IN_PROGRESS_EXECUTION_ID)).setExecution( @@ -488,13 +469,13 @@ describe('workflowExecutionState.store', () => { data: { resultData: { runData: { Pending: [] } } } as never, }), ); - workflowExecutionStateStore.setActiveExecutionId(null); + executionStateStore.setActiveExecutionId(null); - expect(workflowExecutionStateStore.activeExecutionRunData).toEqual({ Pending: [] }); + expect(executionStateStore.activeExecutionRunData).toEqual({ Pending: [] }); }); it('falls back to displayed id after active is cleared', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( + const executionStateStore = useWorkflowExecutionStateStore( createWorkflowDocumentId('wf-1'), ); useExecutionDataStore(createExecutionDataId('exec-1')).setExecution( @@ -503,69 +484,69 @@ describe('workflowExecutionState.store', () => { data: { resultData: { runData: { Displayed: [] } } } as never, }), ); - workflowExecutionStateStore.setActiveExecutionId('exec-1'); - workflowExecutionStateStore.setActiveExecutionId(undefined); + executionStateStore.setActiveExecutionId('exec-1'); + executionStateStore.setActiveExecutionId(undefined); - expect(workflowExecutionStateStore.activeExecutionRunData).toEqual({ Displayed: [] }); + expect(executionStateStore.activeExecutionRunData).toEqual({ Displayed: [] }); }); it('returns null when no execution is tracked', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( + const executionStateStore = useWorkflowExecutionStateStore( createWorkflowDocumentId('wf-1'), ); - expect(workflowExecutionStateStore.activeExecutionRunData).toBeNull(); + expect(executionStateStore.activeExecutionRunData).toBeNull(); }); }); describe('activeExecutionExecutedNode', () => { it('proxies the executedNode from the active executionData store', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( + const executionStateStore = useWorkflowExecutionStateStore( createWorkflowDocumentId('wf-1'), ); useExecutionDataStore(createExecutionDataId('exec-1')).setExecution( makeExecution({ id: 'exec-1', executedNode: 'Code' }), ); - workflowExecutionStateStore.setActiveExecutionId('exec-1'); + executionStateStore.setActiveExecutionId('exec-1'); - expect(workflowExecutionStateStore.activeExecutionExecutedNode).toBe('Code'); + expect(executionStateStore.activeExecutionExecutedNode).toBe('Code'); }); it('falls back to IN_PROGRESS for null activeExecutionId', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( + const executionStateStore = useWorkflowExecutionStateStore( createWorkflowDocumentId('wf-1'), ); useExecutionDataStore(createExecutionDataId(IN_PROGRESS_EXECUTION_ID)).setExecution( makeExecution({ id: IN_PROGRESS_EXECUTION_ID, executedNode: 'Pending' }), ); - workflowExecutionStateStore.setActiveExecutionId(null); + executionStateStore.setActiveExecutionId(null); - expect(workflowExecutionStateStore.activeExecutionExecutedNode).toBe('Pending'); + expect(executionStateStore.activeExecutionExecutedNode).toBe('Pending'); }); it('falls back to displayed id after active is cleared', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( + const executionStateStore = useWorkflowExecutionStateStore( createWorkflowDocumentId('wf-1'), ); useExecutionDataStore(createExecutionDataId('exec-1')).setExecution( makeExecution({ id: 'exec-1', executedNode: 'Trigger' }), ); - workflowExecutionStateStore.setActiveExecutionId('exec-1'); - workflowExecutionStateStore.setActiveExecutionId(undefined); + executionStateStore.setActiveExecutionId('exec-1'); + executionStateStore.setActiveExecutionId(undefined); - expect(workflowExecutionStateStore.activeExecutionExecutedNode).toBe('Trigger'); + expect(executionStateStore.activeExecutionExecutedNode).toBe('Trigger'); }); it('returns undefined when nothing is set', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( + const executionStateStore = useWorkflowExecutionStateStore( createWorkflowDocumentId('wf-1'), ); - expect(workflowExecutionStateStore.activeExecutionExecutedNode).toBeUndefined(); + expect(executionStateStore.activeExecutionExecutedNode).toBeUndefined(); }); }); describe('activeExecutionStartedData', () => { it('proxies executionStartedData for string activeExecutionId', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( + const executionStateStore = useWorkflowExecutionStateStore( createWorkflowDocumentId('wf-1'), ); const executionDataStore = useExecutionDataStore(createExecutionDataId('exec-1')); @@ -575,14 +556,14 @@ describe('workflowExecutionState.store', () => { nodeName: 'Code', data: { startTime: 1 } as never, }); - workflowExecutionStateStore.setActiveExecutionId('exec-1'); + executionStateStore.setActiveExecutionId('exec-1'); - expect(workflowExecutionStateStore.activeExecutionStartedData?.[0]).toBe('exec-1'); - expect(workflowExecutionStateStore.activeExecutionStartedData?.[1].Code).toHaveLength(1); + expect(executionStateStore.activeExecutionStartedData?.[0]).toBe('exec-1'); + expect(executionStateStore.activeExecutionStartedData?.[1].Code).toHaveLength(1); }); it('falls back to IN_PROGRESS for null activeExecutionId', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( + const executionStateStore = useWorkflowExecutionStateStore( createWorkflowDocumentId('wf-1'), ); const executionDataStore = useExecutionDataStore( @@ -594,25 +575,23 @@ describe('workflowExecutionState.store', () => { nodeName: 'Pending', data: { startTime: 1 } as never, }); - workflowExecutionStateStore.setActiveExecutionId(null); + executionStateStore.setActiveExecutionId(null); - expect(workflowExecutionStateStore.activeExecutionStartedData?.[0]).toBe( - IN_PROGRESS_EXECUTION_ID, - ); - expect(workflowExecutionStateStore.activeExecutionStartedData?.[1].Pending).toHaveLength(1); + expect(executionStateStore.activeExecutionStartedData?.[0]).toBe(IN_PROGRESS_EXECUTION_ID); + expect(executionStateStore.activeExecutionStartedData?.[1].Pending).toHaveLength(1); }); it('returns undefined when nothing is set', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( + const executionStateStore = useWorkflowExecutionStateStore( createWorkflowDocumentId('wf-1'), ); - expect(workflowExecutionStateStore.activeExecutionStartedData).toBeUndefined(); + expect(executionStateStore.activeExecutionStartedData).toBeUndefined(); }); }); describe('activeExecutionPairedItemMappings', () => { it('proxies pairedItemMappings for string activeExecutionId', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( + const executionStateStore = useWorkflowExecutionStateStore( createWorkflowDocumentId('wf-1'), ); useExecutionDataStore(createExecutionDataId('exec-1')).setExecution( @@ -621,83 +600,75 @@ describe('workflowExecutionState.store', () => { const expected = useExecutionDataStore( createExecutionDataId('exec-1'), ).executionPairedItemMappings; - workflowExecutionStateStore.setActiveExecutionId('exec-1'); + executionStateStore.setActiveExecutionId('exec-1'); - expect(workflowExecutionStateStore.activeExecutionPairedItemMappings).toBe(expected); + expect(executionStateStore.activeExecutionPairedItemMappings).toBe(expected); }); it('returns empty object when no execution is tracked', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( + const executionStateStore = useWorkflowExecutionStateStore( createWorkflowDocumentId('wf-1'), ); - expect(workflowExecutionStateStore.activeExecutionPairedItemMappings).toEqual({}); + expect(executionStateStore.activeExecutionPairedItemMappings).toEqual({}); }); }); }); describe('setWorkflowExecutionData (execution-data store writes)', () => { it('null clears pending and displayed execution ids', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.setPendingExecution(makeExecution()); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.setPendingExecution(makeExecution()); useExecutionDataStore(createExecutionDataId('exec-1')).setExecution( makeExecution({ id: 'exec-1' }), ); - workflowExecutionStateStore.setActiveExecutionId('exec-1'); + executionStateStore.setActiveExecutionId('exec-1'); - workflowExecutionStateStore.setWorkflowExecutionData(null); + executionStateStore.setWorkflowExecutionData(null); - expect(workflowExecutionStateStore.pendingExecution).toBeNull(); - expect(workflowExecutionStateStore.displayedExecutionId).toBeUndefined(); + expect(executionStateStore.pendingExecution).toBeNull(); + expect(executionStateStore.displayedExecutionId).toBeUndefined(); }); it('IN_PROGRESS payload stages pending + activeExecutionId=null + writes IN_PROGRESS data store', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); const payload = makeExecution({ id: IN_PROGRESS_EXECUTION_ID, executedNode: 'Code' }); - workflowExecutionStateStore.setWorkflowExecutionData(payload); + executionStateStore.setWorkflowExecutionData(payload); - expect(workflowExecutionStateStore.activeExecutionId).toBeNull(); - expect(workflowExecutionStateStore.pendingExecution?.id).toBe(IN_PROGRESS_EXECUTION_ID); + expect(executionStateStore.activeExecutionId).toBeNull(); + expect(executionStateStore.pendingExecution?.id).toBe(IN_PROGRESS_EXECUTION_ID); expect( useExecutionDataStore(createExecutionDataId(IN_PROGRESS_EXECUTION_ID)).execution?.id, ).toBe(IN_PROGRESS_EXECUTION_ID); }); it('real id without prior active sets data store, clears pending, promotes displayed id', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.setPendingExecution(makeExecution()); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.setPendingExecution(makeExecution()); const exec = makeExecution({ id: 'exec-real' }); - workflowExecutionStateStore.setWorkflowExecutionData(exec); + executionStateStore.setWorkflowExecutionData(exec); expect(useExecutionDataStore(createExecutionDataId('exec-real')).execution?.id).toBe( 'exec-real', ); - expect(workflowExecutionStateStore.pendingExecution).toBeNull(); - expect(workflowExecutionStateStore.activeExecutionId).toBeUndefined(); - expect(workflowExecutionStateStore.displayedExecutionId).toBe('exec-real'); + expect(executionStateStore.pendingExecution).toBeNull(); + expect(executionStateStore.activeExecutionId).toBeUndefined(); + expect(executionStateStore.displayedExecutionId).toBe('exec-real'); }); it('real id while activeExecutionId is already a string updates the data store only', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); useExecutionDataStore(createExecutionDataId('exec-1')).setExecution( makeExecution({ id: 'exec-1' }), ); - workflowExecutionStateStore.setActiveExecutionId('exec-1'); + executionStateStore.setActiveExecutionId('exec-1'); const updated = makeExecution({ id: 'exec-1', executedNode: 'Code' }); - workflowExecutionStateStore.setWorkflowExecutionData(updated); + executionStateStore.setWorkflowExecutionData(updated); - expect(workflowExecutionStateStore.activeExecutionId).toBe('exec-1'); - expect(workflowExecutionStateStore.displayedExecutionId).toBe('exec-1'); + expect(executionStateStore.activeExecutionId).toBe('exec-1'); + expect(executionStateStore.displayedExecutionId).toBe('exec-1'); expect(useExecutionDataStore(createExecutionDataId('exec-1')).execution?.executedNode).toBe( 'Code', ); @@ -706,15 +677,13 @@ describe('workflowExecutionState.store', () => { describe('setActiveExecutionRunData / clearActiveExecutionStartedData / addActiveNodeExecutionStartedData', () => { it('setActiveExecutionRunData routes through the resolved id', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); useExecutionDataStore(createExecutionDataId('exec-1')).setExecution( makeExecution({ id: 'exec-1' }), ); - workflowExecutionStateStore.setActiveExecutionId('exec-1'); + executionStateStore.setActiveExecutionId('exec-1'); - workflowExecutionStateStore.setActiveExecutionRunData({ + executionStateStore.setActiveExecutionRunData({ resultData: { runData: { Code: [] } }, } as never); @@ -724,15 +693,13 @@ describe('workflowExecutionState.store', () => { }); it('addActiveNodeExecutionStartedData routes through the resolved id', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); useExecutionDataStore(createExecutionDataId('exec-1')).setExecution( makeExecution({ id: 'exec-1' }), ); - workflowExecutionStateStore.setActiveExecutionId('exec-1'); + executionStateStore.setActiveExecutionId('exec-1'); - workflowExecutionStateStore.addActiveNodeExecutionStartedData({ + executionStateStore.addActiveNodeExecutionStartedData({ executionId: 'exec-1', nodeName: 'Code', data: { startTime: 1 } as never, @@ -744,9 +711,7 @@ describe('workflowExecutionState.store', () => { }); it('clearActiveExecutionStartedData routes through the resolved id', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); const executionDataStore = useExecutionDataStore(createExecutionDataId('exec-1')); executionDataStore.setExecution(makeExecution({ id: 'exec-1' })); executionDataStore.addNodeExecutionStartedData({ @@ -754,26 +719,24 @@ describe('workflowExecutionState.store', () => { nodeName: 'Code', data: { startTime: 1 } as never, }); - workflowExecutionStateStore.setActiveExecutionId('exec-1'); + executionStateStore.setActiveExecutionId('exec-1'); - workflowExecutionStateStore.clearActiveExecutionStartedData(); + executionStateStore.clearActiveExecutionStartedData(); expect(executionDataStore.executionStartedData).toBeUndefined(); }); it('writes go nowhere when no execution is tracked', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); expect(() => - workflowExecutionStateStore.setActiveExecutionRunData({ + executionStateStore.setActiveExecutionRunData({ resultData: { runData: {} }, } as never), ).not.toThrow(); - expect(() => workflowExecutionStateStore.clearActiveExecutionStartedData()).not.toThrow(); + expect(() => executionStateStore.clearActiveExecutionStartedData()).not.toThrow(); expect(() => - workflowExecutionStateStore.addActiveNodeExecutionStartedData({ + executionStateStore.addActiveNodeExecutionStartedData({ executionId: 'x', nodeName: 'N', data: { startTime: 1 } as never, @@ -784,22 +747,20 @@ describe('workflowExecutionState.store', () => { describe('promotePendingExecution', () => { it('migrates the pending scaffold into a fresh executionData store and sets activeExecutionId', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); const scaffold = makeExecution({ id: '__IN_PROGRESS__', data: { resultData: { runData: { Trigger: [{ executionStatus: 'success' } as never] } }, } as never, }); - workflowExecutionStateStore.setPendingExecution(scaffold); - workflowExecutionStateStore.setActiveExecutionId(null); + executionStateStore.setPendingExecution(scaffold); + executionStateStore.setActiveExecutionId(null); - workflowExecutionStateStore.promotePendingExecution('exec-real'); + executionStateStore.promotePendingExecution('exec-real'); - expect(workflowExecutionStateStore.activeExecutionId).toBe('exec-real'); - expect(workflowExecutionStateStore.pendingExecution).toBeNull(); + expect(executionStateStore.activeExecutionId).toBe('exec-real'); + expect(executionStateStore.pendingExecution).toBeNull(); const executionDataStore = useExecutionDataStore(createExecutionDataId('exec-real')); expect(executionDataStore.execution?.id).toBe('exec-real'); @@ -807,20 +768,16 @@ describe('workflowExecutionState.store', () => { }); it('still sets activeExecutionId when no pending scaffold exists', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); - workflowExecutionStateStore.promotePendingExecution('exec-real'); + executionStateStore.promotePendingExecution('exec-real'); - expect(workflowExecutionStateStore.activeExecutionId).toBe('exec-real'); - expect(workflowExecutionStateStore.pendingExecution).toBeNull(); + expect(executionStateStore.activeExecutionId).toBe('exec-real'); + expect(executionStateStore.pendingExecution).toBeNull(); }); it('setActiveExecutionId(string) migrates a staged pending scaffold into the id-keyed store', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); const scaffold = makeExecution({ id: '__IN_PROGRESS__', executedNode: 'Code', @@ -828,13 +785,13 @@ describe('workflowExecutionState.store', () => { resultData: { runData: {} }, } as never, }); - workflowExecutionStateStore.setPendingExecution(scaffold); - workflowExecutionStateStore.setActiveExecutionId(null); + executionStateStore.setPendingExecution(scaffold); + executionStateStore.setActiveExecutionId(null); - workflowExecutionStateStore.setActiveExecutionId('exec-real'); + executionStateStore.setActiveExecutionId('exec-real'); - expect(workflowExecutionStateStore.activeExecutionId).toBe('exec-real'); - expect(workflowExecutionStateStore.pendingExecution).toBeNull(); + expect(executionStateStore.activeExecutionId).toBe('exec-real'); + expect(executionStateStore.pendingExecution).toBeNull(); const executionDataStore = useExecutionDataStore(createExecutionDataId('exec-real')); expect(executionDataStore.execution?.id).toBe('exec-real'); @@ -842,14 +799,12 @@ describe('workflowExecutionState.store', () => { }); it('setActiveExecutionId(string) without a pending scaffold does not promote', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); - workflowExecutionStateStore.setActiveExecutionId('exec-real'); + executionStateStore.setActiveExecutionId('exec-real'); - expect(workflowExecutionStateStore.activeExecutionId).toBe('exec-real'); - expect(workflowExecutionStateStore.pendingExecution).toBeNull(); + expect(executionStateStore.activeExecutionId).toBe('exec-real'); + expect(executionStateStore.pendingExecution).toBeNull(); const executionDataStore = useExecutionDataStore(createExecutionDataId('exec-real')); expect(executionDataStore.execution).toBeNull(); }); @@ -857,282 +812,236 @@ describe('workflowExecutionState.store', () => { describe('isWorkflowRunning', () => { it('is true when activeExecutionId is null (pending)', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.setActiveExecutionId(null); - expect(workflowExecutionStateStore.isWorkflowRunning).toBe(true); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.setActiveExecutionId(null); + expect(executionStateStore.isWorkflowRunning).toBe(true); }); it('is true when active execution is running and not finished', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); useExecutionDataStore(createExecutionDataId('exec-1')).setExecution( makeExecution({ id: 'exec-1', status: 'running', finished: false }), ); - workflowExecutionStateStore.setActiveExecutionId('exec-1'); - expect(workflowExecutionStateStore.isWorkflowRunning).toBe(true); + executionStateStore.setActiveExecutionId('exec-1'); + expect(executionStateStore.isWorkflowRunning).toBe(true); }); it('is false when active execution is finished', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); useExecutionDataStore(createExecutionDataId('exec-1')).setExecution( makeExecution({ id: 'exec-1', status: 'success', finished: true }), ); - workflowExecutionStateStore.setActiveExecutionId('exec-1'); - expect(workflowExecutionStateStore.isWorkflowRunning).toBe(false); + executionStateStore.setActiveExecutionId('exec-1'); + expect(executionStateStore.isWorkflowRunning).toBe(false); }); it('is false when no active execution is tracked', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - expect(workflowExecutionStateStore.isWorkflowRunning).toBe(false); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + expect(executionStateStore.isWorkflowRunning).toBe(false); }); }); describe('currentWorkflowExecutions', () => { it('addToCurrentExecutions filters by workflowId', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.addToCurrentExecutions([ + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.addToCurrentExecutions([ makeExecutionSummary({ id: '1', workflowId: 'wf-1' }), makeExecutionSummary({ id: '2', workflowId: 'other-wf' }), ]); - expect(workflowExecutionStateStore.currentWorkflowExecutions).toHaveLength(1); - expect(workflowExecutionStateStore.currentWorkflowExecutions[0].id).toBe('1'); + expect(executionStateStore.currentWorkflowExecutions).toHaveLength(1); + expect(executionStateStore.currentWorkflowExecutions[0].id).toBe('1'); }); it('addToCurrentExecutions deduplicates by id', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.addToCurrentExecutions([ + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.addToCurrentExecutions([ makeExecutionSummary({ id: '1', workflowId: 'wf-1' }), ]); - workflowExecutionStateStore.addToCurrentExecutions([ + executionStateStore.addToCurrentExecutions([ makeExecutionSummary({ id: '1', workflowId: 'wf-1' }), makeExecutionSummary({ id: '2', workflowId: 'wf-1' }), ]); - expect(workflowExecutionStateStore.currentWorkflowExecutions.map((e) => e.id)).toEqual([ - '1', - '2', - ]); + expect(executionStateStore.currentWorkflowExecutions.map((e) => e.id)).toEqual(['1', '2']); }); it('deleteExecution accepts both summary and id', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); const a = makeExecutionSummary({ id: '1', workflowId: 'wf-1' }); const b = makeExecutionSummary({ id: '2', workflowId: 'wf-1' }); - workflowExecutionStateStore.setCurrentWorkflowExecutions([a, b]); + executionStateStore.setCurrentWorkflowExecutions([a, b]); - workflowExecutionStateStore.deleteExecution(a); - expect(workflowExecutionStateStore.currentWorkflowExecutions.map((e) => e.id)).toEqual(['2']); + executionStateStore.deleteExecution(a); + expect(executionStateStore.currentWorkflowExecutions.map((e) => e.id)).toEqual(['2']); - workflowExecutionStateStore.deleteExecution('2'); - expect(workflowExecutionStateStore.currentWorkflowExecutions).toEqual([]); + executionStateStore.deleteExecution('2'); + expect(executionStateStore.currentWorkflowExecutions).toEqual([]); }); it('clearCurrentWorkflowExecutions empties the list', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.setCurrentWorkflowExecutions([makeExecutionSummary({ id: '1' })]); - workflowExecutionStateStore.clearCurrentWorkflowExecutions(); - expect(workflowExecutionStateStore.currentWorkflowExecutions).toEqual([]); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.setCurrentWorkflowExecutions([makeExecutionSummary({ id: '1' })]); + executionStateStore.clearCurrentWorkflowExecutions(); + expect(executionStateStore.currentWorkflowExecutions).toEqual([]); }); it('getAllLoadedFinishedExecutions filters by finished or stoppedAt', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.setCurrentWorkflowExecutions([ + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.setCurrentWorkflowExecutions([ makeExecutionSummary({ id: '1', finished: true }), makeExecutionSummary({ id: '2', finished: false }), makeExecutionSummary({ id: '3', finished: false, stoppedAt: new Date() }), ]); - expect( - workflowExecutionStateStore.getAllLoadedFinishedExecutions.map((e) => e.id).sort(), - ).toEqual(['1', '3']); + expect(executionStateStore.getAllLoadedFinishedExecutions.map((e) => e.id).sort()).toEqual([ + '1', + '3', + ]); }); }); describe('lastSuccessfulExecution', () => { it('stores execution as id reference and resolves through executionData store', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); const exec = makeExecution({ id: 'last-1', status: 'success', finished: true }); - workflowExecutionStateStore.setLastSuccessfulExecution(exec); + executionStateStore.setLastSuccessfulExecution(exec); - expect(workflowExecutionStateStore.lastSuccessfulExecutionId).toBe('last-1'); - expect(workflowExecutionStateStore.lastSuccessfulExecution?.id).toBe('last-1'); + expect(executionStateStore.lastSuccessfulExecutionId).toBe('last-1'); + expect(executionStateStore.lastSuccessfulExecution?.id).toBe('last-1'); }); it('is independent of active/displayed execution', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); useExecutionDataStore(createExecutionDataId('active-1')).setExecution( makeExecution({ id: 'active-1' }), ); - workflowExecutionStateStore.setActiveExecutionId('active-1'); - workflowExecutionStateStore.setLastSuccessfulExecution(makeExecution({ id: 'last-1' })); + executionStateStore.setActiveExecutionId('active-1'); + executionStateStore.setLastSuccessfulExecution(makeExecution({ id: 'last-1' })); - expect(workflowExecutionStateStore.activeExecutionId).toBe('active-1'); - expect(workflowExecutionStateStore.lastSuccessfulExecutionId).toBe('last-1'); - expect(workflowExecutionStateStore.activeExecution?.id).toBe('active-1'); - expect(workflowExecutionStateStore.lastSuccessfulExecution?.id).toBe('last-1'); + expect(executionStateStore.activeExecutionId).toBe('active-1'); + expect(executionStateStore.lastSuccessfulExecutionId).toBe('last-1'); + expect(executionStateStore.activeExecution?.id).toBe('active-1'); + expect(executionStateStore.lastSuccessfulExecution?.id).toBe('last-1'); }); it('disposes the previous executionData store entry on replacement', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.setLastSuccessfulExecution(makeExecution({ id: 'last-1' })); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.setLastSuccessfulExecution(makeExecution({ id: 'last-1' })); const pinia = getActivePinia(); const previousStoreId = useExecutionDataStore(createExecutionDataId('last-1')).$id; - workflowExecutionStateStore.setLastSuccessfulExecution(makeExecution({ id: 'last-2' })); + executionStateStore.setLastSuccessfulExecution(makeExecution({ id: 'last-2' })); expect(pinia?.state.value[previousStoreId]).toBeUndefined(); - expect(workflowExecutionStateStore.lastSuccessfulExecution?.id).toBe('last-2'); + expect(executionStateStore.lastSuccessfulExecution?.id).toBe('last-2'); }); it('clears via null', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.setLastSuccessfulExecution(makeExecution({ id: 'last-1' })); - workflowExecutionStateStore.setLastSuccessfulExecution(null); - expect(workflowExecutionStateStore.lastSuccessfulExecutionId).toBeNull(); - expect(workflowExecutionStateStore.lastSuccessfulExecution).toBeNull(); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.setLastSuccessfulExecution(makeExecution({ id: 'last-1' })); + executionStateStore.setLastSuccessfulExecution(null); + expect(executionStateStore.lastSuccessfulExecutionId).toBeNull(); + expect(executionStateStore.lastSuccessfulExecution).toBeNull(); }); it('does not dispose the previous store when it is also the active execution', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); useExecutionDataStore(createExecutionDataId('A')).setExecution(makeExecution({ id: 'A' })); - workflowExecutionStateStore.setActiveExecutionId('A'); - workflowExecutionStateStore.setLastSuccessfulExecution(makeExecution({ id: 'A' })); + executionStateStore.setActiveExecutionId('A'); + executionStateStore.setLastSuccessfulExecution(makeExecution({ id: 'A' })); const pinia = getActivePinia(); const aStoreId = useExecutionDataStore(createExecutionDataId('A')).$id; - workflowExecutionStateStore.setLastSuccessfulExecution(makeExecution({ id: 'B' })); + executionStateStore.setLastSuccessfulExecution(makeExecution({ id: 'B' })); expect(pinia?.state.value[aStoreId]).toBeDefined(); - expect(workflowExecutionStateStore.activeExecution?.id).toBe('A'); - expect(workflowExecutionStateStore.lastSuccessfulExecution?.id).toBe('B'); + expect(executionStateStore.activeExecution?.id).toBe('A'); + expect(executionStateStore.lastSuccessfulExecution?.id).toBe('B'); }); it('does not dispose the previous store when it is also the displayed execution', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); useExecutionDataStore(createExecutionDataId('A')).setExecution(makeExecution({ id: 'A' })); - workflowExecutionStateStore.setActiveExecutionId('A'); - workflowExecutionStateStore.setLastSuccessfulExecution(makeExecution({ id: 'A' })); - workflowExecutionStateStore.setActiveExecutionId(undefined); + executionStateStore.setActiveExecutionId('A'); + executionStateStore.setLastSuccessfulExecution(makeExecution({ id: 'A' })); + executionStateStore.setActiveExecutionId(undefined); const pinia = getActivePinia(); const aStoreId = useExecutionDataStore(createExecutionDataId('A')).$id; - workflowExecutionStateStore.setLastSuccessfulExecution(makeExecution({ id: 'B' })); + executionStateStore.setLastSuccessfulExecution(makeExecution({ id: 'B' })); expect(pinia?.state.value[aStoreId]).toBeDefined(); - expect(workflowExecutionStateStore.displayedExecutionId).toBe('A'); - expect(workflowExecutionStateStore.activeExecution?.id).toBe('A'); - expect(workflowExecutionStateStore.lastSuccessfulExecution?.id).toBe('B'); + expect(executionStateStore.displayedExecutionId).toBe('A'); + expect(executionStateStore.activeExecution?.id).toBe('A'); + expect(executionStateStore.lastSuccessfulExecution?.id).toBe('B'); }); }); describe('chat + trigger + flags', () => { it('appendChatMessage / resetChatMessages round-trip', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.appendChatMessage('hello'); - workflowExecutionStateStore.appendChatMessage('world'); - expect(workflowExecutionStateStore.chatMessages).toEqual(['hello', 'world']); - expect(workflowExecutionStateStore.getPastChatMessages).toEqual(['hello', 'world']); - workflowExecutionStateStore.resetChatMessages(); - expect(workflowExecutionStateStore.chatMessages).toEqual([]); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.appendChatMessage('hello'); + executionStateStore.appendChatMessage('world'); + expect(executionStateStore.chatMessages).toEqual(['hello', 'world']); + expect(executionStateStore.getPastChatMessages).toEqual(['hello', 'world']); + executionStateStore.resetChatMessages(); + expect(executionStateStore.chatMessages).toEqual([]); }); it('setSelectedTriggerNodeName updates the value', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.setSelectedTriggerNodeName('TriggerA'); - expect(workflowExecutionStateStore.selectedTriggerNodeName).toBe('TriggerA'); - workflowExecutionStateStore.setSelectedTriggerNodeName(undefined); - expect(workflowExecutionStateStore.selectedTriggerNodeName).toBeUndefined(); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.setSelectedTriggerNodeName('TriggerA'); + expect(executionStateStore.selectedTriggerNodeName).toBe('TriggerA'); + executionStateStore.setSelectedTriggerNodeName(undefined); + expect(executionStateStore.selectedTriggerNodeName).toBeUndefined(); }); it('setExecutionWaitingForWebhook / setIsInDebugMode toggle flags', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.setExecutionWaitingForWebhook(true); - workflowExecutionStateStore.setIsInDebugMode(true); - expect(workflowExecutionStateStore.executionWaitingForWebhook).toBe(true); - expect(workflowExecutionStateStore.isInDebugMode).toBe(true); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.setExecutionWaitingForWebhook(true); + executionStateStore.setIsInDebugMode(true); + expect(executionStateStore.executionWaitingForWebhook).toBe(true); + expect(executionStateStore.isInDebugMode).toBe(true); }); it('setChatPartialExecutionDestinationNode round-trip', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.setChatPartialExecutionDestinationNode('NodeA'); - expect(workflowExecutionStateStore.chatPartialExecutionDestinationNode).toBe('NodeA'); - workflowExecutionStateStore.setChatPartialExecutionDestinationNode(null); - expect(workflowExecutionStateStore.chatPartialExecutionDestinationNode).toBeNull(); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.setChatPartialExecutionDestinationNode('NodeA'); + expect(executionStateStore.chatPartialExecutionDestinationNode).toBe('NodeA'); + executionStateStore.setChatPartialExecutionDestinationNode(null); + expect(executionStateStore.chatPartialExecutionDestinationNode).toBeNull(); }); }); describe('renameExecutionStateNode', () => { it('renames selectedTriggerNodeName and chatPartialExecutionDestinationNode', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.setSelectedTriggerNodeName('Old'); - workflowExecutionStateStore.setChatPartialExecutionDestinationNode('Old'); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.setSelectedTriggerNodeName('Old'); + executionStateStore.setChatPartialExecutionDestinationNode('Old'); - workflowExecutionStateStore.renameExecutionStateNode('Old', 'New'); + executionStateStore.renameExecutionStateNode('Old', 'New'); - expect(workflowExecutionStateStore.selectedTriggerNodeName).toBe('New'); - expect(workflowExecutionStateStore.chatPartialExecutionDestinationNode).toBe('New'); + expect(executionStateStore.selectedTriggerNodeName).toBe('New'); + expect(executionStateStore.chatPartialExecutionDestinationNode).toBe('New'); }); it('does nothing when names do not match', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.setSelectedTriggerNodeName('A'); - workflowExecutionStateStore.setChatPartialExecutionDestinationNode('B'); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.setSelectedTriggerNodeName('A'); + executionStateStore.setChatPartialExecutionDestinationNode('B'); - workflowExecutionStateStore.renameExecutionStateNode('Other', 'Renamed'); + executionStateStore.renameExecutionStateNode('Other', 'Renamed'); - expect(workflowExecutionStateStore.selectedTriggerNodeName).toBe('A'); - expect(workflowExecutionStateStore.chatPartialExecutionDestinationNode).toBe('B'); + expect(executionStateStore.selectedTriggerNodeName).toBe('A'); + expect(executionStateStore.chatPartialExecutionDestinationNode).toBe('B'); }); }); describe('renameActiveExecutionNode (cross-store)', () => { it('renames runData keys on the resolved executionData store', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); const executionDataStore = useExecutionDataStore(createExecutionDataId('exec-1')); executionDataStore.setExecution( makeExecution({ @@ -1144,9 +1053,9 @@ describe('workflowExecutionState.store', () => { } as never, }), ); - workflowExecutionStateStore.setActiveExecutionId('exec-1'); + executionStateStore.setActiveExecutionId('exec-1'); - workflowExecutionStateStore.renameActiveExecutionNode({ old: 'Old', new: 'New' }); + executionStateStore.renameActiveExecutionNode({ old: 'Old', new: 'New' }); const runData = executionDataStore.execution?.data?.resultData.runData; expect(runData?.New).toBeDefined(); @@ -1154,16 +1063,14 @@ describe('workflowExecutionState.store', () => { }); it('renames selectedTriggerNodeName and chatPartialExecutionDestinationNode', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.setSelectedTriggerNodeName('Old'); - workflowExecutionStateStore.setChatPartialExecutionDestinationNode('Old'); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.setSelectedTriggerNodeName('Old'); + executionStateStore.setChatPartialExecutionDestinationNode('Old'); - workflowExecutionStateStore.renameActiveExecutionNode({ old: 'Old', new: 'New' }); + executionStateStore.renameActiveExecutionNode({ old: 'Old', new: 'New' }); - expect(workflowExecutionStateStore.selectedTriggerNodeName).toBe('New'); - expect(workflowExecutionStateStore.chatPartialExecutionDestinationNode).toBe('New'); + expect(executionStateStore.selectedTriggerNodeName).toBe('New'); + expect(executionStateStore.chatPartialExecutionDestinationNode).toBe('New'); }); it('marks UI state dirty and remaps uiStore.lastSelectedNode when it matches', async () => { @@ -1172,11 +1079,9 @@ describe('workflowExecutionState.store', () => { uiStore.lastSelectedNode = 'Old'; const markDirtySpy = vi.spyOn(uiStore, 'markStateDirty'); - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); - workflowExecutionStateStore.renameActiveExecutionNode({ old: 'Old', new: 'New' }); + executionStateStore.renameActiveExecutionNode({ old: 'Old', new: 'New' }); expect(markDirtySpy).toHaveBeenCalled(); expect(uiStore.lastSelectedNode).toBe('New'); @@ -1187,18 +1092,14 @@ describe('workflowExecutionState.store', () => { const uiStore = useUIStore(); uiStore.lastSelectedNode = 'Untouched'; - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.renameActiveExecutionNode({ old: 'Old', new: 'New' }); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.renameActiveExecutionNode({ old: 'Old', new: 'New' }); expect(uiStore.lastSelectedNode).toBe('Untouched'); }); it('routes through the displayedExecutionId when active is cleared', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); const executionDataStore = useExecutionDataStore(createExecutionDataId('exec-1')); executionDataStore.setExecution( makeExecution({ @@ -1206,10 +1107,10 @@ describe('workflowExecutionState.store', () => { data: { resultData: { runData: { Old: [] } } } as never, }), ); - workflowExecutionStateStore.setActiveExecutionId('exec-1'); - workflowExecutionStateStore.setActiveExecutionId(undefined); + executionStateStore.setActiveExecutionId('exec-1'); + executionStateStore.setActiveExecutionId(undefined); - workflowExecutionStateStore.renameActiveExecutionNode({ old: 'Old', new: 'New' }); + executionStateStore.renameActiveExecutionNode({ old: 'Old', new: 'New' }); expect(executionDataStore.execution?.data?.resultData.runData.New).toBeDefined(); }); @@ -1217,9 +1118,7 @@ describe('workflowExecutionState.store', () => { describe('resolveExecutionTriggerNodeName', () => { it('returns triggerNode from active execution', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); useExecutionDataStore(createExecutionDataId('exec-1')).setExecution( makeExecution({ id: 'exec-1', @@ -1228,17 +1127,15 @@ describe('workflowExecutionState.store', () => { finished: false, }), ); - workflowExecutionStateStore.setActiveExecutionId('exec-1'); + executionStateStore.setActiveExecutionId('exec-1'); - expect( - workflowExecutionStateStore.resolveExecutionTriggerNodeName(['TriggerA', 'TriggerB']), - ).toBe('TriggerA'); + expect(executionStateStore.resolveExecutionTriggerNodeName(['TriggerA', 'TriggerB'])).toBe( + 'TriggerA', + ); }); it('falls back to runData keys for partial executions', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); useExecutionDataStore(createExecutionDataId('exec-1')).setExecution( makeExecution({ id: 'exec-1', @@ -1247,76 +1144,68 @@ describe('workflowExecutionState.store', () => { data: { resultData: { runData: { TriggerB: [] } } } as never, }), ); - workflowExecutionStateStore.setActiveExecutionId('exec-1'); + executionStateStore.setActiveExecutionId('exec-1'); - expect( - workflowExecutionStateStore.resolveExecutionTriggerNodeName(['TriggerA', 'TriggerB']), - ).toBe('TriggerB'); + expect(executionStateStore.resolveExecutionTriggerNodeName(['TriggerA', 'TriggerB'])).toBe( + 'TriggerB', + ); }); it('returns undefined when not running', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - expect( - workflowExecutionStateStore.resolveExecutionTriggerNodeName(['TriggerA']), - ).toBeUndefined(); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + expect(executionStateStore.resolveExecutionTriggerNodeName(['TriggerA'])).toBeUndefined(); }); }); describe('resetExecutionState', () => { it('clears every state field', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.setActiveExecutionId('exec-1'); - workflowExecutionStateStore.setPendingExecution(makeExecution()); - workflowExecutionStateStore.setExecutionWaitingForWebhook(true); - workflowExecutionStateStore.setIsInDebugMode(true); - workflowExecutionStateStore.appendChatMessage('hi'); - workflowExecutionStateStore.setChatPartialExecutionDestinationNode('Node'); - workflowExecutionStateStore.setSelectedTriggerNodeName('Trigger'); - workflowExecutionStateStore.setCurrentWorkflowExecutions([makeExecutionSummary({ id: '1' })]); - workflowExecutionStateStore.setLastSuccessfulExecutionId('last-1'); - workflowExecutionStateStore.executingNode.addExecutingNode('Node'); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.setActiveExecutionId('exec-1'); + executionStateStore.setPendingExecution(makeExecution()); + executionStateStore.setExecutionWaitingForWebhook(true); + executionStateStore.setIsInDebugMode(true); + executionStateStore.appendChatMessage('hi'); + executionStateStore.setChatPartialExecutionDestinationNode('Node'); + executionStateStore.setSelectedTriggerNodeName('Trigger'); + executionStateStore.setCurrentWorkflowExecutions([makeExecutionSummary({ id: '1' })]); + executionStateStore.setLastSuccessfulExecutionId('last-1'); + executionStateStore.executingNode.addExecutingNode('Node'); - workflowExecutionStateStore.resetExecutionState(); + executionStateStore.resetExecutionState(); - expect(workflowExecutionStateStore.activeExecutionId).toBeUndefined(); - expect(workflowExecutionStateStore.displayedExecutionId).toBeUndefined(); - expect(workflowExecutionStateStore.previousExecutionId).toBeUndefined(); - expect(workflowExecutionStateStore.pendingExecution).toBeNull(); - expect(workflowExecutionStateStore.executionWaitingForWebhook).toBe(false); - expect(workflowExecutionStateStore.isInDebugMode).toBe(false); - expect(workflowExecutionStateStore.chatMessages).toEqual([]); - expect(workflowExecutionStateStore.chatPartialExecutionDestinationNode).toBeNull(); - expect(workflowExecutionStateStore.selectedTriggerNodeName).toBeUndefined(); - expect(workflowExecutionStateStore.currentWorkflowExecutions).toEqual([]); - expect(workflowExecutionStateStore.lastSuccessfulExecutionId).toBeNull(); - expect(workflowExecutionStateStore.executingNode.executingNode).toEqual([]); - expect(workflowExecutionStateStore.executingNode.lastAddedExecutingNode).toBeNull(); + expect(executionStateStore.activeExecutionId).toBeUndefined(); + expect(executionStateStore.displayedExecutionId).toBeUndefined(); + expect(executionStateStore.previousExecutionId).toBeUndefined(); + expect(executionStateStore.pendingExecution).toBeNull(); + expect(executionStateStore.executionWaitingForWebhook).toBe(false); + expect(executionStateStore.isInDebugMode).toBe(false); + expect(executionStateStore.chatMessages).toEqual([]); + expect(executionStateStore.chatPartialExecutionDestinationNode).toBeNull(); + expect(executionStateStore.selectedTriggerNodeName).toBeUndefined(); + expect(executionStateStore.currentWorkflowExecutions).toEqual([]); + expect(executionStateStore.lastSuccessfulExecutionId).toBeNull(); + expect(executionStateStore.executingNode.executingNode).toEqual([]); + expect(executionStateStore.executingNode.lastAddedExecutingNode).toBeNull(); }); it('disposes per-execution data stores for every tracked id', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); // Three sequential runs β€” exec-1 rolls out of previousExecutionId after exec-3. - workflowExecutionStateStore.setActiveExecutionId('exec-1'); + executionStateStore.setActiveExecutionId('exec-1'); useExecutionDataStore(createExecutionDataId('exec-1')).setExecution( makeExecution({ id: 'exec-1' }), ); - workflowExecutionStateStore.setActiveExecutionId('exec-2'); + executionStateStore.setActiveExecutionId('exec-2'); useExecutionDataStore(createExecutionDataId('exec-2')).setExecution( makeExecution({ id: 'exec-2' }), ); - workflowExecutionStateStore.setActiveExecutionId('exec-3'); + executionStateStore.setActiveExecutionId('exec-3'); useExecutionDataStore(createExecutionDataId('exec-3')).setExecution( makeExecution({ id: 'exec-3' }), ); - workflowExecutionStateStore.resetExecutionState(); + executionStateStore.resetExecutionState(); // All three stores must be disposed β€” even exec-1 which is in no slot. expect(useExecutionDataStore(createExecutionDataId('exec-1')).execution).toBeNull(); @@ -1325,17 +1214,15 @@ describe('workflowExecutionState.store', () => { }); it('disposes the IN_PROGRESS placeholder store', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.setPendingExecution( + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.setPendingExecution( makeExecution({ id: IN_PROGRESS_EXECUTION_ID, status: 'running' }), ); useExecutionDataStore(createExecutionDataId(IN_PROGRESS_EXECUTION_ID)).setExecution( makeExecution({ id: IN_PROGRESS_EXECUTION_ID }), ); - workflowExecutionStateStore.resetExecutionState(); + executionStateStore.resetExecutionState(); expect( useExecutionDataStore(createExecutionDataId(IN_PROGRESS_EXECUTION_ID)).execution, @@ -1369,13 +1256,11 @@ describe('workflowExecutionState.store', () => { describe('trackExecutionId', () => { it('tracks ids written via setActiveExecutionId across rolling runs', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); - workflowExecutionStateStore.setActiveExecutionId('exec-1'); - workflowExecutionStateStore.setActiveExecutionId('exec-2'); - workflowExecutionStateStore.setActiveExecutionId('exec-3'); + executionStateStore.setActiveExecutionId('exec-1'); + executionStateStore.setActiveExecutionId('exec-2'); + executionStateStore.setActiveExecutionId('exec-3'); useExecutionDataStore(createExecutionDataId('exec-1')).setExecution( makeExecution({ id: 'exec-1' }), @@ -1387,7 +1272,7 @@ describe('workflowExecutionState.store', () => { makeExecution({ id: 'exec-3' }), ); - workflowExecutionStateStore.resetExecutionState(); + executionStateStore.resetExecutionState(); // All three are disposed even though exec-1 is in no slot after run 3. expect(useExecutionDataStore(createExecutionDataId('exec-1')).execution).toBeNull(); @@ -1396,24 +1281,20 @@ describe('workflowExecutionState.store', () => { }); it('tracks ids written via setLastSuccessfulExecution', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.setLastSuccessfulExecution( + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.setLastSuccessfulExecution( makeExecution({ id: 'exec-success', finished: true, status: 'success' }), ); - workflowExecutionStateStore.resetExecutionState(); + executionStateStore.resetExecutionState(); expect(useExecutionDataStore(createExecutionDataId('exec-success')).execution).toBeNull(); }); it('does not track the IN_PROGRESS placeholder id (handled separately)', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.trackExecutionId(IN_PROGRESS_EXECUTION_ID); - workflowExecutionStateStore.trackExecutionId('real-exec'); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.trackExecutionId(IN_PROGRESS_EXECUTION_ID); + executionStateStore.trackExecutionId('real-exec'); useExecutionDataStore(createExecutionDataId('real-exec')).setExecution( makeExecution({ id: 'real-exec' }), @@ -1422,7 +1303,7 @@ describe('workflowExecutionState.store', () => { makeExecution({ id: IN_PROGRESS_EXECUTION_ID }), ); - workflowExecutionStateStore.resetExecutionState(); + executionStateStore.resetExecutionState(); // Both stores get disposed: real-exec via tracked set, IN_PROGRESS unconditionally. expect(useExecutionDataStore(createExecutionDataId('real-exec')).execution).toBeNull(); @@ -1432,26 +1313,24 @@ describe('workflowExecutionState.store', () => { }); it('ignores null and undefined ids', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.trackExecutionId(null); - workflowExecutionStateStore.trackExecutionId(undefined); - workflowExecutionStateStore.trackExecutionId(''); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.trackExecutionId(null); + executionStateStore.trackExecutionId(undefined); + executionStateStore.trackExecutionId(''); // resetExecutionState must complete without error and produce no surprising side effects. - expect(() => workflowExecutionStateStore.resetExecutionState()).not.toThrow(); + expect(() => executionStateStore.resetExecutionState()).not.toThrow(); }); }); describe('event hook', () => { it('fires onWorkflowExecutionStateChange with workflowId and field discriminator', () => { const id = createWorkflowDocumentId('wf-1'); - const workflowExecutionStateStore = useWorkflowExecutionStateStore(id); + const executionStateStore = useWorkflowExecutionStateStore(id); const spy = vi.fn(); - workflowExecutionStateStore.onWorkflowExecutionStateChange(spy); + executionStateStore.onWorkflowExecutionStateChange(spy); - workflowExecutionStateStore.setExecutionWaitingForWebhook(true); + executionStateStore.setExecutionWaitingForWebhook(true); expect(spy).toHaveBeenCalled(); expect(spy.mock.calls[0][0]).toMatchObject({ @@ -1461,14 +1340,12 @@ describe('workflowExecutionState.store', () => { }); it('emits a delete action when clearing pending execution', () => { - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId('wf-1'), - ); - workflowExecutionStateStore.setPendingExecution(makeExecution()); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId('wf-1')); + executionStateStore.setPendingExecution(makeExecution()); const spy = vi.fn(); - workflowExecutionStateStore.onWorkflowExecutionStateChange(spy); - workflowExecutionStateStore.setPendingExecution(null); + executionStateStore.onWorkflowExecutionStateChange(spy); + executionStateStore.setPendingExecution(null); expect(spy.mock.calls.some((c) => c[0].action === 'delete')).toBe(true); }); @@ -1477,23 +1354,46 @@ describe('workflowExecutionState.store', () => { describe('disposeWorkflowExecutionStateStore', () => { it('removes pinia state and recreate yields fresh state', () => { const id = 'wf-disposable'; - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId(id), - ); + const executionStateStore = useWorkflowExecutionStateStore(createWorkflowDocumentId(id)); const pinia = getActivePinia(); - const disposeSpy = vi.spyOn(workflowExecutionStateStore, '$dispose'); + const disposeSpy = vi.spyOn(executionStateStore, '$dispose'); - workflowExecutionStateStore.setExecutionWaitingForWebhook(true); - expect(pinia?.state.value[workflowExecutionStateStore.$id]).toBeDefined(); + executionStateStore.setExecutionWaitingForWebhook(true); + expect(pinia?.state.value[executionStateStore.$id]).toBeDefined(); - disposeWorkflowExecutionStateStore(workflowExecutionStateStore); + disposeWorkflowExecutionStateStore(executionStateStore); expect(disposeSpy).toHaveBeenCalledOnce(); - expect(pinia?.state.value[workflowExecutionStateStore.$id]).toBeUndefined(); + expect(pinia?.state.value[executionStateStore.$id]).toBeUndefined(); const recreated = useWorkflowExecutionStateStore(createWorkflowDocumentId(id)); - expect(recreated).not.toBe(workflowExecutionStateStore); + expect(recreated).not.toBe(executionStateStore); expect(recreated.executionWaitingForWebhook).toBe(false); }); + + it('releases running entries created from node-change events on dispose', () => { + const id = createWorkflowDocumentId('wf-dispose-running'); + const documentStore = useWorkflowDocumentStore(id); + const executionStateStore = useWorkflowExecutionStateStore(id); + + // Fired after store setup, so the entry's effect scope is created + // outside any active parent scope. + documentStore.addNode({ + id: 'node-1', + name: 'Node 1', + position: [0, 0], + type: 'n8n-nodes-base.set', + typeVersion: 1, + parameters: {}, + }); + + expect(executionStateStore.executionRunningByNodeId.has('node-1')).toBe(true); + expect(executionStateStore.executionWaitingForNextByNodeId.has('node-1')).toBe(true); + + disposeWorkflowExecutionStateStore(executionStateStore); + + expect(executionStateStore.executionRunningByNodeId.size).toBe(0); + expect(executionStateStore.executionWaitingForNextByNodeId.size).toBe(0); + }); }); }); diff --git a/packages/frontend/editor-ui/src/app/stores/workflowExecutionState.store.ts b/packages/frontend/editor-ui/src/app/stores/workflowExecutionState.store.ts index 646d4026747..2dd5cd31402 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflowExecutionState.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/workflowExecutionState.store.ts @@ -1,8 +1,18 @@ import { defineStore, getActivePinia } from 'pinia'; import { STORES } from '@n8n/stores'; -import { computed, inject, readonly, ref, type ComputedRef } from 'vue'; +import { + computed, + effectScope, + inject, + onScopeDispose, + readonly, + ref, + shallowReactive, + type ComputedRef, +} from 'vue'; import { createEventHook } from '@vueuse/core'; -import type { ExecutionSummary, IRunExecutionData } from 'n8n-workflow'; +import { structuralComputed } from '@n8n/composables/structuralComputed'; +import type { ExecutionStatus, ExecutionSummary, IRunExecutionData, ITaskData } from 'n8n-workflow'; import type { NodeExecuteBefore } from '@n8n/api-types/push/execution'; import type { IExecutionResponse, @@ -22,8 +32,19 @@ import { useDocumentTitle } from '@/app/composables/useDocumentTitle'; import { clearPopupWindowState } from '@/features/execution/executions/executions.utils'; import { CHANGE_ACTION } from './workflowDocument/types'; import type { ChangeAction, ChangeEvent } from './workflowDocument/types'; +import type { + NodeAddedPayload, + NodeRemovedPayload, + NodesChangeEvent, + NodesSetPayload, +} from './workflowDocument/useWorkflowDocumentNodes'; +import type { ExecutionOutputMap } from '@/app/types/executionData'; const EMPTY_EXECUTION_ISSUES_BY_NODE_NAME = new Map>(); +const EMPTY_EXECUTION_STATUS_BY_NODE_ID = new Map>(); +const EMPTY_EXECUTION_RUN_DATA_BY_NODE_ID = new Map>(); +const EMPTY_EXECUTION_RUN_DATA_OUTPUT_MAP_BY_NODE_ID = new Map(); +const EMPTY_EXECUTION_WAITING_BY_NODE_ID = new Map>(); export type WorkflowExecutionStateChangePayload = { documentId: WorkflowDocumentId; @@ -236,6 +257,38 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) { return EMPTY_EXECUTION_ISSUES_BY_NODE_NAME; }); + // Active/displayed/pending fallback for the per-node-id execution data + // projections. Resolves the backing execution id via + // `getResolvedActiveExecutionId()` (string id β†’ that execution, pending + // `null` β†’ IN_PROGRESS scaffold, else displayed id) so these stay + // consistent with `activeExecutionRunData`; falls back to an empty Map + // sentinel only when no execution is being tracked. + + const activeExecutionStatusByNodeId = computed(() => { + const executionId = getResolvedActiveExecutionId(); + if (!executionId) return EMPTY_EXECUTION_STATUS_BY_NODE_ID; + return useExecutionDataStore(createExecutionDataId(executionId)).executionStatusByNodeId; + }); + + const activeExecutionRunDataByNodeId = computed(() => { + const executionId = getResolvedActiveExecutionId(); + if (!executionId) return EMPTY_EXECUTION_RUN_DATA_BY_NODE_ID; + return useExecutionDataStore(createExecutionDataId(executionId)).executionRunDataByNodeId; + }); + + const activeExecutionRunDataOutputMapByNodeId = computed(() => { + const executionId = getResolvedActiveExecutionId(); + if (!executionId) return EMPTY_EXECUTION_RUN_DATA_OUTPUT_MAP_BY_NODE_ID; + return useExecutionDataStore(createExecutionDataId(executionId)) + .executionRunDataOutputMapByNodeId; + }); + + const activeExecutionWaitingByNodeId = computed(() => { + const executionId = getResolvedActiveExecutionId(); + if (!executionId) return EMPTY_EXECUTION_WAITING_BY_NODE_ID; + return useExecutionDataStore(createExecutionDataId(executionId)).executionWaitingByNodeId; + }); + const lastSuccessfulExecution = computed(() => { const lid = lastSuccessfulExecutionId.value; if (!lid) return null; @@ -255,6 +308,121 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) { return false; }); + // --------------------------------------------------------------------- + // Per-node-id "is this node mid-execution?" projections. + // + // Reconciled against the matching workflowDocument store's `onNodesChange`. + // Each per-entry structuralComputed reads the `executingNode` refs + // reactively, so add/remove calls invalidate only that entry β€” and only + // when the *value* changes (gated by structural equality). + // --------------------------------------------------------------------- + + const documentStore = useWorkflowDocumentStore(documentId); + + const executionRunningByNodeId = shallowReactive(new Map>()); + const executionWaitingForNextByNodeId = shallowReactive( + new Map>(), + ); + const runningScopes = new Map void>(); + + function computeExecutionRunning(nodeId: string): boolean { + // `nodesById` is a top-level shallowRef inside useWorkflowDocumentNodes; + // Pinia unwraps it to a Map at the store boundary. + const node = documentStore.nodesById.get(nodeId); + if (!node) return false; + return executingNode.isNodeExecuting(node.name); + } + + function computeExecutionWaitingForNext(nodeId: string): boolean { + const node = documentStore.nodesById.get(nodeId); + if (!node) return false; + return ( + node.name === executingNode.lastAddedExecutingNode.value && + executingNode.executingNode.value.length === 0 && + isWorkflowRunning.value + ); + } + + function applyAddRunningEntry(nodeId: string) { + if (runningScopes.has(nodeId)) return; + const scope = effectScope(); + scope.run(() => { + executionRunningByNodeId.set( + nodeId, + structuralComputed(() => computeExecutionRunning(nodeId)), + ); + executionWaitingForNextByNodeId.set( + nodeId, + structuralComputed(() => computeExecutionWaitingForNext(nodeId)), + ); + }); + runningScopes.set(nodeId, () => scope.stop()); + } + + function applyRemoveRunningEntry(nodeId: string) { + runningScopes.get(nodeId)?.(); + runningScopes.delete(nodeId); + executionRunningByNodeId.delete(nodeId); + executionWaitingForNextByNodeId.delete(nodeId); + } + + function applyReconcileRunningEntries(nodeIds: string[]) { + const next = new Set(nodeIds); + for (const old of runningScopes.keys()) { + if (!next.has(old)) applyRemoveRunningEntry(old); + } + for (const id of nodeIds) applyAddRunningEntry(id); + } + + // Subscribe lazily and defensively. Some test files mock + // `useWorkflowDocumentStore` with a partial object that lacks + // `onNodesChange` / `nodesById`. The guard keeps the dependency soft for + // tests that don't exercise the running maps; in production the document + // store always provides the full surface. + if (typeof documentStore.onNodesChange === 'function') { + documentStore.onNodesChange((event: NodesChangeEvent) => { + switch (event.action) { + case CHANGE_ACTION.ADD: { + const { node } = event.payload as NodeAddedPayload; + applyAddRunningEntry(node.id); + break; + } + case CHANGE_ACTION.DELETE: { + const payload = event.payload as NodeRemovedPayload; + if (payload.id) { + applyRemoveRunningEntry(payload.id); + } else { + applyReconcileRunningEntries([]); + } + break; + } + case CHANGE_ACTION.SET: { + const { nodeIds } = event.payload as NodesSetPayload; + applyReconcileRunningEntries(nodeIds); + break; + } + } + }); + } + + const initialNodesById = documentStore.nodesById; + if (initialNodesById && typeof initialNodesById.keys === 'function') { + applyReconcileRunningEntries(Array.from(initialNodesById.keys())); + } + + // Scopes created from `onNodesChange` callbacks have no active parent + // (event dispatch runs outside any scope), so `$dispose()` never + // reaches them. Vue 3.5 computeds are not scope-owned and detach from + // deps once unsubscribed, so this is deterministic cleanup hygiene + // rather than leak prevention: stop the scopes and drop the per-node + // entries when the store is disposed. + onScopeDispose(() => { + for (const stop of runningScopes.values()) stop(); + runningScopes.clear(); + executionRunningByNodeId.clear(); + executionWaitingForNextByNodeId.clear(); + }); + /** * Resolves the trigger node name driving the active execution. * Falls back to scanning runData keys for partial executions. @@ -669,6 +837,12 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) { getPastChatMessages, getActiveExecutionRunDataByNodeName, activeExecutionIssuesByNodeName, + activeExecutionStatusByNodeId, + activeExecutionRunDataByNodeId, + activeExecutionRunDataOutputMapByNodeId, + activeExecutionWaitingByNodeId, + executionRunningByNodeId, + executionWaitingForNextByNodeId, resolveExecutionTriggerNodeName, // Write API trackExecutionId, diff --git a/packages/frontend/editor-ui/src/app/types/executionData.ts b/packages/frontend/editor-ui/src/app/types/executionData.ts new file mode 100644 index 00000000000..66dc3ef27f7 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/types/executionData.ts @@ -0,0 +1,29 @@ +/** + * Per-output aggregation of execution run data. + * + * `total` β€” items emitted across all iterations. + * `iterations` β€” number of non-canceled task iterations contributing. + * `byTarget` β€” present for non-main connection types (e.g. AI tools / memory / + * embeddings). Tracks the same `{ total, iterations }` per target node id, so + * label rendering can show counts per fan-out target. Keyed by node id. + */ +export type ExecutionOutputMapData = { + total: number; + iterations: number; + byTarget?: { + [targetNodeId: string]: { + total: number; + iterations: number; + }; + }; +}; + +/** + * Aggregated output stats for a single node, indexed first by connection type + * (`main`, `ai_tool`, etc.) and then by output index (as a string). + */ +export type ExecutionOutputMap = { + [connectionType: string]: { + [outputIndex: string]: ExecutionOutputMapData; + }; +}; diff --git a/packages/frontend/editor-ui/src/app/types/index.ts b/packages/frontend/editor-ui/src/app/types/index.ts index 653b1e612db..c160c66ad56 100644 --- a/packages/frontend/editor-ui/src/app/types/index.ts +++ b/packages/frontend/editor-ui/src/app/types/index.ts @@ -1,3 +1,4 @@ +export type * from './executionData'; export type * from './externalHooks'; export type * from './pushConnection'; export type * from './workflow'; diff --git a/packages/frontend/editor-ui/src/app/utils/nodeTypesUtils.ts b/packages/frontend/editor-ui/src/app/utils/nodeTypesUtils.ts index 38314870b39..8f0ad78057a 100644 --- a/packages/frontend/editor-ui/src/app/utils/nodeTypesUtils.ts +++ b/packages/frontend/editor-ui/src/app/utils/nodeTypesUtils.ts @@ -10,8 +10,10 @@ import { MAIN_AUTH_FIELD_NAME, MAPPING_PARAMS, NON_ACTIVATABLE_TRIGGER_NODE_TYPES, + PLACEHOLDER_FILLED_AT_EXECUTION_TIME, TEMPLATES_NODES_FILTER, } from '@/app/constants'; +import type { WorkflowObjectAccessors } from '@/app/types/workflow'; import { i18n as locale } from '@n8n/i18n'; import { useCredentialsStore } from '@/features/credentials/credentials.store'; import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; @@ -19,9 +21,11 @@ import { isJsonKeyObject } from '@/app/utils/typesUtils'; import { isResourceLocatorValue, type IDataObject, + type INode, type INodeCredentialDescription, type INodeExecutionData, type INodeProperties, + type INodePropertyOptions, type INodeTypeDescription, type NodeParameterValueType, type ResourceMapperField, @@ -62,6 +66,71 @@ export function getTriggerNodeServiceName(nodeType: INodeTypeDescription): strin return nodeType.displayName.replace(/ trigger/i, ''); } +/** + * Derives the subtitle displayed under a node's name on the canvas. + * + * Pure function: reads only its parameters β€” it must not access stores or + * `inject()`, because `useWorkflowDocumentRenderData` calls it from detached + * effect scopes outside component setup (watch callbacks in + * `WorkflowCanvas.vue` / `useWorkflowDiff.ts`). + */ +export function getNodeSubtitle( + data: INode, + nodeType: INodeTypeDescription, + workflow: WorkflowObjectAccessors, +): string | undefined { + if (!data) { + return undefined; + } + + if (data.notesInFlow) { + return data.notes; + } + + if (nodeType?.subtitle !== undefined) { + try { + return workflow.expression.getSimpleParameterValue( + data, + nodeType.subtitle, + 'internal', + {}, + undefined, + PLACEHOLDER_FILLED_AT_EXECUTION_TIME, + ) as string | undefined; + } catch (e) { + return undefined; + } + } + + if (data.parameters.operation !== undefined) { + const operation = data.parameters.operation as string; + if (nodeType === null) { + return operation; + } + + const operationData = nodeType.properties.find((property: INodeProperties) => { + return property.name === 'operation'; + }); + if (operationData === undefined) { + return operation; + } + + if (operationData.options === undefined) { + return operation; + } + + const optionData = operationData.options.find((option) => { + return (option as INodePropertyOptions).value === data.parameters.operation; + }); + if (optionData === undefined) { + return operation; + } + + return optionData.name; + } + return undefined; +} + export function getActivatableTriggerNodes(nodes: INodeUi[]) { return nodes.filter( (node: INodeUi) => !node.disabled && !NON_ACTIVATABLE_TRIGGER_NODE_TYPES.includes(node.type), diff --git a/packages/frontend/editor-ui/src/app/utils/nodes/nodeTransforms.ts b/packages/frontend/editor-ui/src/app/utils/nodes/nodeTransforms.ts index 2c32b472f60..d456c06e0c7 100644 --- a/packages/frontend/editor-ui/src/app/utils/nodes/nodeTransforms.ts +++ b/packages/frontend/editor-ui/src/app/utils/nodes/nodeTransforms.ts @@ -17,6 +17,20 @@ import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; import { getCredentialTypeName, isCredentialOnlyNodeType } from '@/app/utils/credentialOnlyNodes'; import { hasProxyAuth } from '@/app/utils/nodeTypesUtils'; +/** + * Assigns a freshly generated id to the given node and returns it. + * + * Pure with respect to application state: it touches only its argument β€” it + * must not access stores or `inject()`, because the workflow document store + * wires it into `useWorkflowDocumentNodes` and may itself be constructed + * outside component setup (e.g. from `useWorkflowDiff`'s watch effects). + */ +export function assignNodeId(node: INodeUi): string { + const id = window.crypto.randomUUID(); + node.id = id; + return id; +} + /** * Returns the credentials that are displayable for the given node. */ diff --git a/packages/frontend/editor-ui/src/features/ndv/panel/components/OutputPanel.vue b/packages/frontend/editor-ui/src/features/ndv/panel/components/OutputPanel.vue index 5cd94cc5628..fc91289bea9 100644 --- a/packages/frontend/editor-ui/src/features/ndv/panel/components/OutputPanel.vue +++ b/packages/frontend/editor-ui/src/features/ndv/panel/components/OutputPanel.vue @@ -87,7 +87,7 @@ const workflowExecutionStateStore = computed(() => const telemetry = useTelemetry(); const i18n = useI18n(); const activeNode = computed(() => ndvStore.value.activeNode); -const { dirtinessByName } = useNodeDirtiness(); +const { dirtinessByName } = useNodeDirtiness(() => workflowDocumentStore.value.documentId); const uiStore = useUIStore(); // Composables diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/__tests__/utils.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/__tests__/utils.ts index 5c9e5c88dd4..355bc92609b 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/__tests__/utils.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/__tests__/utils.ts @@ -8,8 +8,8 @@ import type { CanvasNodeHandleInjectionData, CanvasNodeInjectionData, ConnectStartEvent, - ExecutionOutputMapData, } from '@/features/workflows/canvas/canvas.types'; +import type { ExecutionOutputMapData } from '@/app/types/executionData'; import { CanvasConnectionMode, CanvasNodeRenderType, diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/canvas.types.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/canvas.types.ts index 7be24c5a23d..6d559641472 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/canvas.types.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/canvas.types.ts @@ -19,6 +19,7 @@ import type { ComputedRef, Ref } from 'vue'; import type { EventBus } from '@n8n/utils/event-bus'; import type { CanvasLayoutSource } from '@/features/workflows/canvas/composables/useCanvasLayout'; import type { NodeIconSource } from '@/app/utils/nodeIcon'; +import type { ExecutionOutputMap, ExecutionOutputMapData } from '@/app/types/executionData'; export const enum CanvasConnectionMode { Input = 'inputs', @@ -243,23 +244,6 @@ export type ConnectStartEvent = { export type CanvasNodeMoveEvent = { id: string; position: CanvasNode['position'] }; -export type ExecutionOutputMapData = { - total: number; - iterations: number; - byTarget?: { - [targetNodeId: string]: { - total: number; - iterations: number; - }; - }; -}; - -export type ExecutionOutputMap = { - [connectionType: string]: { - [outputIndex: string]: ExecutionOutputMapData; - }; -}; - export type BoundingBox = { x: number; y: number; diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/canvas.utils.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/canvas.utils.ts index ab120e763ca..5494663c07f 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/canvas.utils.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/canvas.utils.ts @@ -4,7 +4,7 @@ import type { INodeTypeDescription, NodeConnectionType, } from 'n8n-workflow'; -import type { Ref } from 'vue'; +import { computed, shallowReactive, type Ref } from 'vue'; import type { INodeUi } from '@/Interface'; import type { BoundingBox, @@ -67,6 +67,47 @@ export function injectCanvasRenderData(): Ref { return injectStrict(CanvasRenderDataKey); } +/** + * Builds an empty `CanvasRenderData` object. + * + * `CanvasRenderData` is a wide projection faΓ§ade β€” production code populates + * it via `useWorkflowDocumentRenderData(documentId)`. This helper exists for + * the two cases that can't go through that path: + * - placeholder values before the underlying workflow document is hydrated + * (e.g. the workflow-diff side panels' initial render); + * - test fixtures that only care about a few fields. + * + * Centralizing it here keeps the ~20+ consumers off the hook when new by-id + * projections land β€” they update one default at a time, not 20 mock literals. + */ +export function createEmptyCanvasRenderData( + overrides: Partial = {}, +): CanvasRenderData { + return { + nodeInputsByNodeId: shallowReactive(new Map()), + nodeOutputsByNodeId: shallowReactive(new Map()), + pinnedDataByNodeName: {}, + pinnedDataByNodeId: shallowReactive(new Map()), + nodeTypeDescriptionByNodeId: shallowReactive(new Map()), + isTriggerByNodeId: shallowReactive(new Map()), + subtitleByNodeId: shallowReactive(new Map()), + simulatedNodeTypeDescriptionByNodeId: shallowReactive(new Map()), + validationErrorsByNodeId: shallowReactive(new Map()), + executionIssuesByNodeName: shallowReactive(new Map()), + executionStatusByNodeId: shallowReactive(new Map()), + executionRunDataByNodeId: shallowReactive(new Map()), + executionRunDataOutputMapByNodeId: shallowReactive(new Map()), + executionWaitingByNodeId: shallowReactive(new Map()), + executionRunningByNodeId: shallowReactive(new Map()), + executionWaitingForNextByNodeId: shallowReactive(new Map()), + tooltipByNodeId: shallowReactive(new Map()), + hasIssuesByNodeId: shallowReactive(new Map()), + renderTypeByNodeId: shallowReactive(new Map()), + additionalPropertiesByNodeId: computed(() => ({})), + ...overrides, + }; +} + /** * Maps multiple legacy n8n connections to VueFlow connections */ diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/WorkflowCanvas.vue b/packages/frontend/editor-ui/src/features/workflows/canvas/components/WorkflowCanvas.vue index d0c8223e37b..f2625f2673e 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/WorkflowCanvas.vue +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/WorkflowCanvas.vue @@ -6,8 +6,19 @@ import { createEventBus } from '@n8n/utils/event-bus'; import type { ViewportTransform } from '@vue-flow/core'; import { getRectOfNodes, useVueFlow } from '@vue-flow/core'; import { throttledRef } from '@vueuse/core'; -import { computed, ref, useCssModule, useTemplateRef } from 'vue'; +import { + computed, + effectScope, + onScopeDispose, + ref, + shallowRef, + useCssModule, + useTemplateRef, + watch, + type EffectScope, +} from 'vue'; import type { CanvasEventBusEvents } from '../canvas.types'; +import { createEmptyCanvasRenderData, type CanvasRenderData } from '../canvas.utils'; import { useCanvasMapping } from '../composables/useCanvasMapping'; import { mapGroupsToVueFlowNodes } from '../composables/useCanvasMapping.groups'; import Canvas from './Canvas.vue'; @@ -45,16 +56,31 @@ const props = withDefaults( const canvasRef = useTemplateRef('canvas'); const $style = useCssModule(); const workflowDocumentStore = injectWorkflowDocumentStore(); -const renderData = computed(() => - useWorkflowDocumentRenderData(workflowDocumentStore.value.documentId), + +// `useWorkflowDocumentRenderData` is side-effectful (subscribes to the document +// store and creates per-node effect scopes), so it must run once per document +// id inside a scope we own β€” not inside a re-evaluating `computed`. We rebuild +// it only when the document id actually changes, stopping the previous scope +// (which runs the composable's teardown). The `watch` callback runs outside +// reactive tracking, so the composable's internal reactive reads don't cause +// re-invocation. +const renderData = shallowRef(createEmptyCanvasRenderData()); +let renderDataScope: EffectScope | undefined; +watch( + () => workflowDocumentStore.value.documentId, + (documentId) => { + renderDataScope?.stop(); + renderDataScope = effectScope(true); + renderDataScope.run(() => { + renderData.value = useWorkflowDocumentRenderData(documentId); + }); + }, + { immediate: true }, ); +onScopeDispose(() => renderDataScope?.stop()); const { onNodesInitialized, viewport, viewportRef, getNodes, fitBounds } = useVueFlow(props.id); -const workflowObject = computed(() => - workflowDocumentStore.value.getWorkflowObjectAccessorSnapshot(), -); - const nodes = computed(() => { return props.showFallbackNodes ? [...workflowDocumentStore.value.allNodes, ...props.fallbackNodes] @@ -75,7 +101,6 @@ const { } = useCanvasMapping({ nodes, connections, - workflowObject, renderData, isExperimentalNdvActive, }); diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasLayout.test.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasLayout.test.ts index 8e0e1771f87..abd8c675580 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasLayout.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasLayout.test.ts @@ -1,6 +1,9 @@ import { useVueFlow, type GraphNode, type VueFlowStore } from '@vue-flow/core'; import { computed, ref, shallowRef } from 'vue'; -import type { CanvasRenderData } from '@/features/workflows/canvas/canvas.utils'; +import { + createEmptyCanvasRenderData, + type CanvasRenderData, +} from '@/features/workflows/canvas/canvas.utils'; import { createCanvasGraphEdge, createCanvasGraphNode, @@ -43,12 +46,7 @@ describe('useCanvasLayout', () => { const { layout } = useCanvasLayout( 'test-canvas-id', computed(() => false), - shallowRef({ - nodeInputsByNodeId: new Map(), - nodeOutputsByNodeId: new Map(), - pinnedDataByNodeName: {}, - executionIssuesByNodeName: new Map(), - }), + shallowRef(createEmptyCanvasRenderData()), ); return { layout }; diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.test.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.test.ts index 742af719f5f..b6fa92e5b5e 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.test.ts @@ -1,3639 +1,520 @@ -import type { INode, Workflow } from 'n8n-workflow'; +/** + * Slim test suite for `useCanvasMapping`. + * + * The composable is now a thin glue layer that reads from `CanvasRenderData` + * to assemble `mappedNodes` / `mappedConnections`. The heavy by-id projections + * live in their new owners and are tested there: tooltip / hasIssues / render + * type / sticky z-index in `useWorkflowDocumentRenderData.test.ts`, and the + * per-node execution status / waiting message / output-map aggregation in + * `executionData.store.test.ts`. These tests verify the shape of the canvas + * output and that renderData values flow into the right fields. + */ +import type { ITaskData, IConnections } from 'n8n-workflow'; import { NodeConnectionTypes } from 'n8n-workflow'; -import { setActivePinia } from 'pinia'; -import type { ComputedRef, Ref } from 'vue'; +import { createPinia, setActivePinia } from 'pinia'; import { computed, ref, shallowRef } from 'vue'; -import type { CanvasRenderData } from '@/features/workflows/canvas/canvas.utils'; - import { - createTestNode, - createTestWorkflowObject, - mockNode, - mockNodes, - mockNodeTypeDescription, -} from '@/__tests__/mocks'; -import { mockedStore } from '@/__tests__/utils'; + createEmptyCanvasRenderData, + type CanvasRenderData, +} from '@/features/workflows/canvas/canvas.utils'; import { useCanvasMapping } from '@/features/workflows/canvas/composables/useCanvasMapping'; -import { - FORM_TRIGGER_NODE_TYPE, - MANUAL_TRIGGER_NODE_TYPE, - SET_NODE_TYPE, - STICKY_NODE_TYPE, -} from '@/app/constants'; +import { createTestNode } from '@/__tests__/mocks'; import type { INodeUi } from '@/Interface'; -import { useWorkflowsStore } from '@/app/stores/workflows.store'; -import { - useWorkflowDocumentStore, - createWorkflowDocumentId, -} from '@/app/stores/workflowDocument.store'; -import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store'; -import type { IPinData } from 'n8n-workflow'; -import { - CanvasConnectionMode, - CanvasNodeRenderType, - type CanvasConnectionPort, - type CanvasNodeDefaultRender, -} from '../canvas.types'; -import { createCanvasConnectionHandleString, createCanvasConnectionId } from '../canvas.utils'; -import { STORES } from '@n8n/stores'; -import { useRootStore } from '@n8n/stores/useRootStore'; -import { createTestingPinia } from '@pinia/testing'; +import { CanvasNodeRenderType, type CanvasNodeData } from '../canvas.types'; import { MarkerType } from '@vue-flow/core'; -import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; vi.mock('@n8n/i18n', async (importOriginal) => ({ ...(await importOriginal()), useI18n: () => ({ - shortNodeType: (nodeType: string) => nodeType, - nodeText: (key: string) => ({ - eventTriggerDescription: () => key, - }), - baseText: (key: string, options: { interpolate: { count: number } }) => { + baseText: (key: string, options?: { interpolate: { count: number | string } }) => { + if (!options) return key; if (key === 'ndv.output.items') { - return `${options.interpolate.count} item${options.interpolate.count > 1 ? 's' : ''}`; - } else if (key === 'ndv.output.itemsTotal') { - return `${options.interpolate.count} items total`; - } else { - return key; + const count = Number(options.interpolate.count); + return `${count} item${count > 1 ? 's' : ''}`; } + if (key === 'ndv.output.itemsTotal') return `${options.interpolate.count} items total`; + return key; }, }), })); -const renderNodeInputsMap = new Map>(); -const renderNodeOutputsMap = new Map>(); +function setStatus( + rd: CanvasRenderData, + nodeId: string, + status: NonNullable, +) { + rd.executionStatusByNodeId.set( + nodeId, + computed(() => status), + ); +} -const testRenderData = shallowRef({ - nodeInputsByNodeId: renderNodeInputsMap, - nodeOutputsByNodeId: renderNodeOutputsMap, - pinnedDataByNodeName: {}, - executionIssuesByNodeName: new Map(), -}); - -const emptyRenderData = shallowRef({ - nodeInputsByNodeId: new Map(), - nodeOutputsByNodeId: new Map(), - pinnedDataByNodeName: {}, - executionIssuesByNodeName: new Map(), -}); - -function createRenderDataWithExecutionIssuesByNodeName( - issuesByNodeName: Record = {}, -): Ref { - const executionIssuesByNodeName = new Map>(); - for (const [nodeName, issues] of Object.entries(issuesByNodeName)) { - executionIssuesByNodeName.set( - nodeName, - computed(() => issues), - ); - } - return shallowRef({ - nodeInputsByNodeId: new Map(), - nodeOutputsByNodeId: new Map(), - pinnedDataByNodeName: {}, - executionIssuesByNodeName, - }); +function setRunData(rd: CanvasRenderData, nodeId: string, tasks: ITaskData[] | null) { + rd.executionRunDataByNodeId.set( + nodeId, + computed(() => tasks), + ); } beforeEach(() => { - const pinia = createTestingPinia({ - stubActions: false, - initialState: { - [STORES.WORKFLOWS]: { - workflowExecutionData: {}, - }, - [STORES.NODE_TYPES]: { - nodeTypes: { - [MANUAL_TRIGGER_NODE_TYPE]: { - 1: mockNodeTypeDescription({ - name: MANUAL_TRIGGER_NODE_TYPE, - group: ['trigger'], - }), - }, - [FORM_TRIGGER_NODE_TYPE]: { - 1: mockNodeTypeDescription({ - name: FORM_TRIGGER_NODE_TYPE, - group: ['trigger'], - eventTriggerDescription: 'n8n-nodes-base.formTrigger', - }), - }, - [SET_NODE_TYPE]: { - 1: mockNodeTypeDescription({ - name: SET_NODE_TYPE, - }), - }, - }, - }, - }, - }); - setActivePinia(pinia); - - renderNodeInputsMap.clear(); - renderNodeOutputsMap.clear(); - - // Set workflow ID so document store can be created - const workflowsStore = useWorkflowsStore(); - workflowsStore.setWorkflowId('test-workflow'); + setActivePinia(createPinia()); }); -afterEach(() => { - vi.clearAllMocks(); -}); +describe('useCanvasMapping β€” mapped nodes', () => { + it('maps each node to a CanvasNode with id, label, type, position, and draggable', () => { + const node = createTestNode({ id: 'a', name: 'Alpha', type: 'set' }) as INodeUi; + node.position = [10, 20]; + node.draggable = true; -function setPinData(pinData: IPinData) { - const workflowsStore = useWorkflowsStore(); - const workflowDocumentStore = useWorkflowDocumentStore( - createWorkflowDocumentId(workflowsStore.workflowId), - ); - workflowDocumentStore.setPinData(pinData); -} - -function setWorkflowRunning(running: boolean) { - const workflowsStore = useWorkflowsStore(); - const workflowExecutionStateStore = useWorkflowExecutionStateStore( - createWorkflowDocumentId(workflowsStore.workflowId), - ); - vi.spyOn(workflowExecutionStateStore, 'isWorkflowRunning', 'get').mockReturnValue(running); -} - -function getExecutingNode() { - const workflowsStore = useWorkflowsStore(); - return useWorkflowExecutionStateStore(createWorkflowDocumentId(workflowsStore.workflowId)) - .executingNode; -} - -/** - * Populate the render data maps directly for tests - * that rely on per-node inputs/outputs from the render data. - */ -function setupRenderNodes( - entries: Array<{ - id: string; - inputs: CanvasConnectionPort[]; - outputs?: CanvasConnectionPort[]; - }>, -) { - for (const { id, inputs, outputs = [] } of entries) { - renderNodeInputsMap.set( - id, - computed(() => inputs), - ); - renderNodeOutputsMap.set( - id, - computed(() => outputs), - ); - } -} - -describe('useCanvasMapping', () => { - it('should initialize with default props', () => { - const nodes: INodeUi[] = []; - const connections = {}; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, + const { nodes } = useCanvasMapping({ + nodes: ref([node]), + connections: ref({}), + renderData: shallowRef(createEmptyCanvasRenderData()), }); - const { nodes: mappedNodes, connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), + expect(nodes.value).toHaveLength(1); + const mapped = nodes.value[0]; + expect(mapped.id).toBe('a'); + expect(mapped.label).toBe('Alpha'); + expect(mapped.type).toBe('canvas-node'); + expect(mapped.position).toEqual({ x: 10, y: 20 }); + expect(mapped.draggable).toBe(true); + }); + + it('pulls subtitle from renderData.subtitleByNodeId', () => { + const node = createTestNode({ id: 'a', name: 'Alpha' }) as INodeUi; + const rd = createEmptyCanvasRenderData(); + rd.subtitleByNodeId.set( + 'a', + computed(() => 'Custom subtitle'), + ); + + const { nodes } = useCanvasMapping({ + nodes: ref([node]), + connections: ref({}), + renderData: shallowRef(rd), + }); + + expect(nodes.value[0].data?.subtitle).toBe('Custom subtitle'); + }); + + it('defaults subtitle to empty string when renderData has no entry', () => { + const node = createTestNode({ id: 'a', name: 'Alpha' }) as INodeUi; + + const { nodes } = useCanvasMapping({ + nodes: ref([node]), + connections: ref({}), + renderData: shallowRef(createEmptyCanvasRenderData()), + }); + + expect(nodes.value[0].data?.subtitle).toBe(''); + }); + + it('exposes validation errors and hasIssues from renderData', () => { + const node = createTestNode({ id: 'a', name: 'Alpha' }) as INodeUi; + const rd = createEmptyCanvasRenderData(); + rd.validationErrorsByNodeId.set( + 'a', + computed(() => ['Missing parameter']), + ); + rd.hasIssuesByNodeId.set( + 'a', + computed(() => true), + ); + + const { nodes } = useCanvasMapping({ + nodes: ref([node]), + connections: ref({}), + renderData: shallowRef(rd), + }); + + expect(nodes.value[0].data?.issues.validation).toEqual(['Missing parameter']); + expect(nodes.value[0].data?.issues.visible).toBe(true); + }); + + it('exposes execution state (status, waiting, waitingForNext, running) from renderData', () => { + const node = createTestNode({ id: 'a', name: 'Alpha' }) as INodeUi; + const rd = createEmptyCanvasRenderData(); + setStatus(rd, 'a', 'running'); + rd.executionWaitingByNodeId.set( + 'a', + computed(() => 'Waiting for webhook'), + ); + rd.executionRunningByNodeId.set( + 'a', + computed(() => true), + ); + rd.executionWaitingForNextByNodeId.set( + 'a', + computed(() => true), + ); + + const { nodes } = useCanvasMapping({ + nodes: ref([node]), + connections: ref({}), + renderData: shallowRef(rd), + }); + + expect(nodes.value[0].data?.execution.status).toBe('running'); + expect(nodes.value[0].data?.execution.waiting).toBe('Waiting for webhook'); + expect(nodes.value[0].data?.execution.running).toBe(true); + expect(nodes.value[0].data?.execution.waitingForNext).toBe(true); + }); + + it('exposes runData with iterations (excluding canceled) and outputMap', () => { + const node = createTestNode({ id: 'a', name: 'Alpha' }) as INodeUi; + const rd = createEmptyCanvasRenderData(); + setRunData(rd, 'a', [ + { executionStatus: 'success' } as ITaskData, + { executionStatus: 'canceled' } as ITaskData, + { executionStatus: 'success' } as ITaskData, + ]); + rd.executionRunDataOutputMapByNodeId.set('a', { main: { '0': { total: 7, iterations: 2 } } }); + + const { nodes } = useCanvasMapping({ + nodes: ref([node]), + connections: ref({}), + renderData: shallowRef(rd), + }); + + expect(nodes.value[0].data?.runData.iterations).toBe(2); + expect(nodes.value[0].data?.runData.visible).toBe(true); + expect(nodes.value[0].data?.runData.outputMap).toEqual({ + main: { '0': { total: 7, iterations: 2 } }, + }); + }); + + it('falls back to a render entry derived from node.type when renderData has none', () => { + // Placeholder nodes injected via the canvas's `fallbackNodes` prop + // (AddNodes, ChoicePrompt β€” see NodeView.vue) are not in the workflow + // document, so renderData's `renderTypeByNodeId` has no entry. Their + // `node.type` literally equals the render-type identifier, so + // `{ type: node.type, options: {} }` is the correct render entry. + const addNodes = createTestNode({ + id: 'add-nodes', + name: 'AddNodes', + type: CanvasNodeRenderType.AddNodes, + }) as INodeUi; + const choicePrompt = createTestNode({ + id: 'choice-prompt', + name: 'ChoicePrompt', + type: CanvasNodeRenderType.ChoicePrompt, + }) as INodeUi; + + const { nodes } = useCanvasMapping({ + nodes: ref([addNodes, choicePrompt]), + connections: ref({}), + renderData: shallowRef(createEmptyCanvasRenderData()), + }); + + expect(nodes.value[0].data?.render).toEqual({ + type: CanvasNodeRenderType.AddNodes, + options: {}, + }); + expect(nodes.value[1].data?.render).toEqual({ + type: CanvasNodeRenderType.ChoicePrompt, + options: {}, + }); + }); + + it('passes through render type from renderData.renderTypeByNodeId', () => { + const node = createTestNode({ id: 's', name: 'Sticky' }) as INodeUi; + const rd = createEmptyCanvasRenderData(); + rd.renderTypeByNodeId.set( + 's', + computed( + () => + ({ + type: CanvasNodeRenderType.StickyNote, + options: { width: 200, height: 100, color: 1, content: 'Hi' }, + }) as CanvasNodeData['render'], + ), + ); + + const { nodes } = useCanvasMapping({ + nodes: ref([node]), + connections: ref({}), + renderData: shallowRef(rd), + }); + + expect(nodes.value[0].data?.render.type).toBe(CanvasNodeRenderType.StickyNote); + }); + + it('spreads additionalPropertiesByNodeId onto the mapped CanvasNode', () => { + const node = createTestNode({ id: 'a', name: 'Alpha' }) as INodeUi; + const rd = createEmptyCanvasRenderData({ + additionalPropertiesByNodeId: computed(() => ({ a: { style: { zIndex: -42 } } })), + }); + + const { nodes } = useCanvasMapping({ + nodes: ref([node]), + connections: ref({}), + renderData: shallowRef(rd), + }); + + expect(nodes.value[0].style).toEqual({ zIndex: -42 }); + }); + + it('attaches input/output connections derived from the connections prop', () => { + const alpha = createTestNode({ id: 'a', name: 'Alpha' }) as INodeUi; + const beta = createTestNode({ id: 'b', name: 'Beta' }) as INodeUi; + const connections: IConnections = { + Alpha: { main: [[{ node: 'Beta', type: 'main', index: 0 }]] }, + }; + + const { nodes } = useCanvasMapping({ + nodes: ref([alpha, beta]), connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, + renderData: shallowRef(createEmptyCanvasRenderData()), }); - expect(mappedNodes.value).toEqual([]); - expect(mappedConnections.value).toEqual([]); + const alphaMapped = nodes.value.find((n) => n.id === 'a'); + const betaMapped = nodes.value.find((n) => n.id === 'b'); + expect(alphaMapped?.data?.connections.outputs.main?.[0]?.[0]?.node).toBe('Beta'); + expect(betaMapped?.data?.connections.inputs.main?.[0]?.[0]?.node).toBe('Alpha'); + }); +}); + +describe('useCanvasMapping β€” mapped connections', () => { + function makeWorkflow(connections: IConnections, nodes: INodeUi[] = []) { + const alpha = createTestNode({ id: 'a', name: 'Alpha' }) as INodeUi; + const beta = createTestNode({ id: 'b', name: 'Beta' }) as INodeUi; + const allNodes = nodes.length > 0 ? nodes : [alpha, beta]; + return { allNodes, connections }; + } + + it('produces a canvas-edge connection with closed-arrow marker', () => { + const { allNodes, connections } = makeWorkflow({ + Alpha: { main: [[{ node: 'Beta', type: 'main', index: 0 }]] }, + }); + + const { connections: mapped } = useCanvasMapping({ + nodes: ref(allNodes), + connections: ref(connections), + renderData: shallowRef(createEmptyCanvasRenderData()), + }); + + expect(mapped.value).toHaveLength(1); + expect(mapped.value[0].type).toBe('canvas-edge'); + expect(mapped.value[0].markerEnd).toBe(MarkerType.ArrowClosed); }); - describe('nodes', () => { - it('should map nodes to canvas nodes', () => { - const manualTriggerNode = mockNode({ - name: 'Manual Trigger', - type: MANUAL_TRIGGER_NODE_TYPE, - disabled: false, - }); - const nodes = [manualTriggerNode]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - getExecutingNode().isNodeExecuting = vi.fn().mockReturnValue(false); + it('marks the connection as "error" when the source node has issues', () => { + const { allNodes, connections } = makeWorkflow({ + Alpha: { main: [[{ node: 'Beta', type: 'main', index: 0 }]] }, + }); + const rd = createEmptyCanvasRenderData(); + rd.hasIssuesByNodeId.set( + 'a', + computed(() => true), + ); - const { nodes: mappedNodes } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); + const { connections: mapped } = useCanvasMapping({ + nodes: ref(allNodes), + connections: ref(connections), + renderData: shallowRef(rd), + }); - expect(mappedNodes.value).toEqual([ - { - id: manualTriggerNode.id, - label: manualTriggerNode.name, - type: 'canvas-node', - position: expect.anything(), - draggable: undefined, - data: { - id: manualTriggerNode.id, - name: manualTriggerNode.name, - subtitle: '', - type: manualTriggerNode.type, - typeVersion: expect.anything(), - disabled: false, - execution: { - status: 'new', - running: false, - waiting: undefined, - waitingForNext: false, - }, - issues: { - validation: [], - visible: false, - }, - runData: { - iterations: 0, - outputMap: {}, - visible: false, - }, - connections: { - [CanvasConnectionMode.Input]: {}, - [CanvasConnectionMode.Output]: {}, - }, - render: { - type: CanvasNodeRenderType.Default, - options: { - configurable: false, - configuration: false, - dirtiness: undefined, - icon: { - badge: undefined, - src: '/nodes/test-node/icon.svg', - type: 'file', - }, - placeholder: undefined, - tooltip: undefined, - trigger: true, - }, - }, - }, + expect(mapped.value[0].data?.status).toBe('error'); + }); + + it('marks the connection as "running" when the source is running and has no run data', () => { + const { allNodes, connections } = makeWorkflow({ + Alpha: { main: [[{ node: 'Beta', type: 'main', index: 0 }]] }, + }); + const rd = createEmptyCanvasRenderData(); + rd.executionRunningByNodeId.set( + 'a', + computed(() => true), + ); + + const { connections: mapped } = useCanvasMapping({ + nodes: ref(allNodes), + connections: ref(connections), + renderData: shallowRef(rd), + }); + + expect(mapped.value[0].data?.status).toBe('running'); + }); + + it('marks the connection as "pinned" when source has pin data and run data', () => { + const { allNodes, connections } = makeWorkflow({ + Alpha: { main: [[{ node: 'Beta', type: 'main', index: 0 }]] }, + }); + const rd = createEmptyCanvasRenderData(); + rd.pinnedDataByNodeId.set( + 'a', + computed(() => [{ json: { value: 1 } }]), + ); + setRunData(rd, 'a', [{ executionStatus: 'success' } as ITaskData]); + + const { connections: mapped } = useCanvasMapping({ + nodes: ref(allNodes), + connections: ref(connections), + renderData: shallowRef(rd), + }); + + expect(mapped.value[0].data?.status).toBe('pinned'); + }); + + it('marks the connection as "success" when source produced run data and target has run data too', () => { + const { allNodes, connections } = makeWorkflow({ + Alpha: { main: [[{ node: 'Beta', type: 'main', index: 0 }]] }, + }); + const rd = createEmptyCanvasRenderData(); + setRunData(rd, 'a', [{ executionStatus: 'success' } as ITaskData]); + setRunData(rd, 'b', [{ executionStatus: 'success' } as ITaskData]); + rd.executionRunDataOutputMapByNodeId.set('a', { main: { '0': { total: 3, iterations: 1 } } }); + + const { connections: mapped } = useCanvasMapping({ + nodes: ref(allNodes), + connections: ref(connections), + renderData: shallowRef(rd), + }); + + expect(mapped.value[0].data?.status).toBe('success'); + }); + + it('shows item-count label when source has pin data', () => { + const { allNodes, connections } = makeWorkflow({ + Alpha: { main: [[{ node: 'Beta', type: 'main', index: 0 }]] }, + }); + const rd = createEmptyCanvasRenderData(); + rd.pinnedDataByNodeId.set( + 'a', + computed(() => [{ json: { x: 1 } }, { json: { x: 2 } }]), + ); + + const { connections: mapped } = useCanvasMapping({ + nodes: ref(allNodes), + connections: ref(connections), + renderData: shallowRef(rd), + }); + + expect(mapped.value[0].label).toBe('2 items'); + }); + + it('uses outputMap.byTarget for non-main connections when present', () => { + const tool = createTestNode({ id: 'tool', name: 'Tool' }) as INodeUi; + const target = createTestNode({ id: 'target', name: 'Target' }) as INodeUi; + const connections: IConnections = { + Tool: { + [NodeConnectionTypes.AiTool]: [ + [{ node: 'Target', type: NodeConnectionTypes.AiTool, index: 0 }], + ], + }, + }; + const rd = createEmptyCanvasRenderData(); + setRunData(rd, 'tool', [{ executionStatus: 'success' } as ITaskData]); + setRunData(rd, 'target', [{ executionStatus: 'success' } as ITaskData]); + rd.executionRunDataOutputMapByNodeId.set('tool', { + [NodeConnectionTypes.AiTool]: { + '0': { + total: 4, + iterations: 1, + byTarget: { target: { total: 4, iterations: 1 } }, }, + }, + }); + + const { connections: mapped } = useCanvasMapping({ + nodes: ref([tool, target]), + connections: ref(connections), + renderData: shallowRef(rd), + }); + + expect(mapped.value[0].label).toBe('4 items'); + }); + + it('shows blank label when there is no pin data and no run data', () => { + const { allNodes, connections } = makeWorkflow({ + Alpha: { main: [[{ node: 'Beta', type: 'main', index: 0 }]] }, + }); + + const { connections: mapped } = useCanvasMapping({ + nodes: ref(allNodes), + connections: ref(connections), + renderData: shallowRef(createEmptyCanvasRenderData()), + }); + + expect(mapped.value[0].label).toBe(''); + }); + + describe('connection status with canceled tasks', () => { + // Note: the output-map aggregation counts data items of canceled tasks + // (see executionData.store.test.ts), so runDataTotal can be > 0 while + // the last task is canceled β€” the status logic peeks past it. + function mainConnections(): IConnections { + return { Alpha: { main: [[{ node: 'Beta', type: 'main', index: 0 }]] } }; + } + + it('does not mark the connection successful when the only source task is canceled', () => { + const { allNodes, connections } = makeWorkflow(mainConnections()); + const rd = createEmptyCanvasRenderData(); + setRunData(rd, 'a', [{ executionStatus: 'canceled' } as ITaskData]); + rd.executionRunDataOutputMapByNodeId.set('a', { main: { '0': { total: 1, iterations: 0 } } }); + + const { connections: mapped } = useCanvasMapping({ + nodes: ref(allNodes), + connections: ref(connections), + renderData: shallowRef(rd), + }); + + expect(mapped.value[0].data?.status).toBeUndefined(); + }); + + it('marks the connection successful when the last task is canceled but the previous one succeeded', () => { + const { allNodes, connections } = makeWorkflow(mainConnections()); + const rd = createEmptyCanvasRenderData(); + setRunData(rd, 'a', [ + { executionStatus: 'success' } as ITaskData, + { executionStatus: 'canceled' } as ITaskData, ]); - }); + rd.executionRunDataOutputMapByNodeId.set('a', { main: { '0': { total: 2, iterations: 1 } } }); - it('should handle node disabled state', () => { - const manualTriggerNode = mockNode({ - name: 'Manual Trigger', - type: MANUAL_TRIGGER_NODE_TYPE, - disabled: true, - }); - const nodes = [manualTriggerNode]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - const { nodes: mappedNodes } = useCanvasMapping({ - nodes: ref(nodes), + const { connections: mapped } = useCanvasMapping({ + nodes: ref(allNodes), connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, + renderData: shallowRef(rd), }); - expect(mappedNodes.value[0]?.data?.disabled).toEqual(true); + expect(mapped.value[0].data?.status).toBe('success'); }); - it('should handle execution state', () => { - const manualTriggerNode = mockNode({ - name: 'Manual Trigger', - type: MANUAL_TRIGGER_NODE_TYPE, - disabled: true, - }); - const nodes = [manualTriggerNode]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - getExecutingNode().isNodeExecuting = vi.fn().mockReturnValue(true); - - const { nodes: mappedNodes } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedNodes.value[0]?.data?.execution.running).toEqual(true); - }); - - it('should handle input and output connections', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.Main]: [ - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - - const workflowDocumentStore = useWorkflowDocumentStore( - createWorkflowDocumentId(workflowsStore.workflowId), - ); - workflowDocumentStore.setConnections(connections); - - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - const { nodes: mappedNodes } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedNodes.value[0]?.data?.connections[CanvasConnectionMode.Output]).toHaveProperty( - NodeConnectionTypes.Main, - ); - expect( - mappedNodes.value[0]?.data?.connections[CanvasConnectionMode.Output][ - NodeConnectionTypes.Main - ][0]?.[0], - ).toEqual( - expect.objectContaining({ - node: setNode.name, - type: NodeConnectionTypes.Main, - index: 0, - }), - ); - - expect(mappedNodes.value[1]?.data?.connections[CanvasConnectionMode.Input]).toHaveProperty( - NodeConnectionTypes.Main, - ); - expect( - mappedNodes.value[1]?.data?.connections[CanvasConnectionMode.Input][ - NodeConnectionTypes.Main - ][0]?.[0], - ).toEqual( - expect.objectContaining({ - node: manualTriggerNode.name, - type: NodeConnectionTypes.Main, - index: 0, - }), - ); - }); - }); - - describe('render', () => { - it('should handle render options for default node type', () => { - const manualTriggerNode = mockNode({ - name: 'Manual Trigger', - type: MANUAL_TRIGGER_NODE_TYPE, - disabled: false, - }); - const nodes = [manualTriggerNode]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - const { nodes: mappedNodes } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - const rootStore = mockedStore(useRootStore); - rootStore.baseUrl = 'http://test.local/'; - - expect(mappedNodes.value[0]?.data?.render).toEqual({ - type: CanvasNodeRenderType.Default, - options: { - configurable: false, - configuration: false, - trigger: true, - icon: { - src: 'http://test.local/nodes/test-node/icon.svg', - type: 'file', - }, - }, - }); - }); - - it('should handle render options for addNodes node type', () => { - const addNodesNode = mockNode({ - name: CanvasNodeRenderType.AddNodes, - type: CanvasNodeRenderType.AddNodes, - disabled: false, - }); - const nodes = [addNodesNode]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ - nodes: [], - connections, - }); - - const { nodes: mappedNodes } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedNodes.value[0]?.data?.render).toEqual({ - type: CanvasNodeRenderType.AddNodes, - options: {}, - }); - }); - - it('should handle render options for stickyNote node type', () => { - const stickyNoteNode = mockNode({ - name: 'Sticky', - type: STICKY_NODE_TYPE, - disabled: false, - parameters: { - width: 200, - height: 200, - color: 3, - content: '# Hello world', - }, - }); - const nodes = [stickyNoteNode]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - const { nodes: mappedNodes } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedNodes.value[0]?.data?.render).toEqual({ - type: CanvasNodeRenderType.StickyNote, - options: stickyNoteNode.parameters, - }); - }); - }); - - describe('runData', () => { - describe('nodeExecutionRunDataOutputMapById', () => { - it('should return an empty object when there is no run data', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const nodes: INodeUi[] = []; - const connections = {}; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue(null); - - const { nodeExecutionRunDataOutputMapById } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(nodeExecutionRunDataOutputMapById.value).toEqual({}); - }); - - it('should calculate iterations and total correctly for single node', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const nodes = [createTestNode({ name: 'Node 1' })]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue([ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }, { json: {} }]], - }, - }, - ]); - - const { nodeExecutionRunDataOutputMapById } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(nodeExecutionRunDataOutputMapById.value).toEqual({ - [nodes[0].id]: { - [NodeConnectionTypes.Main]: { - 0: { - iterations: 1, - total: 2, - }, - }, - }, - }); - }); - - it('should handle multiple nodes with different connection types', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const nodes = [ - createTestNode({ id: 'node1', name: 'Node 1' }), - createTestNode({ id: 'node2', name: 'Node 2' }), - ]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { - if (nodeName === 'Node 1') { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }]], - [NodeConnectionTypes.AiAgent]: [[{ json: {} }, { json: {} }]], - }, - }, - ]; - } else if (nodeName === 'Node 2') { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }, { json: {} }, { json: {} }]], - }, - }, - ]; - } - - return null; - }); - - const { nodeExecutionRunDataOutputMapById } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(nodeExecutionRunDataOutputMapById.value).toEqual({ - node1: { - [NodeConnectionTypes.Main]: { - 0: { - iterations: 1, - total: 1, - }, - }, - [NodeConnectionTypes.AiAgent]: { - 0: { - iterations: 1, - total: 2, - byTarget: {}, - }, - }, - }, - node2: { - [NodeConnectionTypes.Main]: { - 0: { - iterations: 1, - total: 3, - }, - }, - }, - }); - }); - - it('handles multiple iterations correctly', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const nodes = [createTestNode({ name: 'Node 1' })]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue([ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }]], - }, - }, - { - startTime: 0, - executionTime: 0, - executionIndex: 1, - source: [], - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }, { json: {} }, { json: {} }]], - }, - }, - { - startTime: 0, - executionTime: 0, - executionIndex: 2, - source: [], - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }, { json: {} }]], - }, - }, - ]); - - const { nodeExecutionRunDataOutputMapById } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(nodeExecutionRunDataOutputMapById.value).toEqual({ - [nodes[0].id]: { - [NodeConnectionTypes.Main]: { - 0: { - iterations: 3, - total: 6, - }, - }, - }, - }); - }); - - it('should not count canceled iterations but still count their data', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const nodes = [createTestNode({ name: 'Node 1' })]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue([ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'success', - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }, { json: {} }]], - }, - }, - { - startTime: 0, - executionTime: 0, - executionIndex: 1, - source: [], - executionStatus: 'canceled', - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }, { json: {} }, { json: {} }]], - }, - }, - { - startTime: 0, - executionTime: 0, - executionIndex: 2, - source: [], - executionStatus: 'success', - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }]], - }, - }, - ]); - - const { nodeExecutionRunDataOutputMapById } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(nodeExecutionRunDataOutputMapById.value).toEqual({ - [nodes[0].id]: { - [NodeConnectionTypes.Main]: { - 0: { - iterations: 2, // Only 2 iterations counted (not the canceled one) - total: 6, // All data items still counted - }, - }, - }, - }); - }); - - it('should handle all canceled iterations correctly', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const nodes = [createTestNode({ name: 'Node 1' })]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue([ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'canceled', - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }, { json: {} }]], - }, - }, - { - startTime: 0, - executionTime: 0, - executionIndex: 1, - source: [], - executionStatus: 'canceled', - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }]], - }, - }, - ]); - - const { nodeExecutionRunDataOutputMapById } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(nodeExecutionRunDataOutputMapById.value).toEqual({ - [nodes[0].id]: { - [NodeConnectionTypes.Main]: { - 0: { - iterations: 0, // No iterations counted since all are canceled - total: 3, // But data items still counted - }, - }, - }, - }); - }); - - it('should populate byTarget field for non-main connections with per-target counts', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const modelNode = createTestNode({ name: 'OpenAI Chat Model' }); - const agent1Node = createTestNode({ name: 'AI Agent 1' }); - const agent2Node = createTestNode({ name: 'AI Agent 2' }); - const nodes = [modelNode, agent1Node, agent2Node]; - const connections = { - [modelNode.name]: { - [NodeConnectionTypes.AiLanguageModel]: [ - [ - { node: agent1Node.name, type: NodeConnectionTypes.AiLanguageModel, index: 0 }, - { node: agent2Node.name, type: NodeConnectionTypes.AiLanguageModel, index: 0 }, - ], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - // Model node has multiple executions from different sources - workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { - if (nodeName === modelNode.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [{ previousNode: agent1Node.name }], - executionStatus: 'success', - data: { - [NodeConnectionTypes.AiLanguageModel]: [[{ json: {} }, { json: {} }]], - }, - }, - { - startTime: 0, - executionTime: 0, - executionIndex: 1, - source: [{ previousNode: agent2Node.name }], - executionStatus: 'success', - data: { - [NodeConnectionTypes.AiLanguageModel]: [[{ json: {} }]], - }, - }, - { - startTime: 0, - executionTime: 0, - executionIndex: 2, - source: [{ previousNode: agent1Node.name }], - executionStatus: 'success', - data: { - [NodeConnectionTypes.AiLanguageModel]: [ - [{ json: {} }, { json: {} }, { json: {} }], - ], - }, - }, - ]; - } - return null; - }); - - const { nodeExecutionRunDataOutputMapById } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - // Should have byTarget field for non-main connections - const modelOutputData = nodeExecutionRunDataOutputMapById.value[modelNode.id]; - expect(modelOutputData).toBeDefined(); - expect(modelOutputData[NodeConnectionTypes.AiLanguageModel]).toBeDefined(); - expect(modelOutputData[NodeConnectionTypes.AiLanguageModel][0]).toBeDefined(); - - const outputData = modelOutputData[NodeConnectionTypes.AiLanguageModel][0]; - - // Check aggregated totals - expect(outputData.iterations).toBe(3); - expect(outputData.total).toBe(6); - - // Check per-target tracking - expect(outputData.byTarget).toBeDefined(); - assert(outputData.byTarget); - - expect(outputData.byTarget[agent1Node.id]).toBeDefined(); - expect(outputData.byTarget[agent2Node.id]).toBeDefined(); - - // Agent 1 was called twice with 2 + 3 = 5 items total - expect(outputData.byTarget[agent1Node.id].iterations).toBe(2); - expect(outputData.byTarget[agent1Node.id].total).toBe(5); - - // Agent 2 was called once with 1 item - expect(outputData.byTarget[agent2Node.id].iterations).toBe(1); - expect(outputData.byTarget[agent2Node.id].total).toBe(1); - }); - - it('should count items inside response field when aggregating for non-main connections', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const embeddingNode = createTestNode({ name: 'Embeddings OpenAI' }); - const vectorStoreNode = createTestNode({ name: 'Vector Store' }); - const nodes = [embeddingNode, vectorStoreNode]; - const connections = { - [embeddingNode.name]: { - [NodeConnectionTypes.AiEmbedding]: [ - [{ node: vectorStoreNode.name, type: NodeConnectionTypes.AiEmbedding, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - // Embedding node returns data wrapped in response field - workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { - if (nodeName === embeddingNode.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [{ previousNode: vectorStoreNode.name }], - executionStatus: 'success', - data: { - [NodeConnectionTypes.AiEmbedding]: [ - [ - { - json: { - response: [ - { embedding: [0.1, 0.2] }, - { embedding: [0.3, 0.4] }, - { embedding: [0.5, 0.6] }, - ], - }, - }, - ], - ], - }, - }, - ]; - } - return null; - }); - - const { nodeExecutionRunDataOutputMapById } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - const embeddingOutputData = nodeExecutionRunDataOutputMapById.value[embeddingNode.id]; - expect(embeddingOutputData).toBeDefined(); - expect(embeddingOutputData[NodeConnectionTypes.AiEmbedding]).toBeDefined(); - expect(embeddingOutputData[NodeConnectionTypes.AiEmbedding][0]).toBeDefined(); - - const outputData = embeddingOutputData[NodeConnectionTypes.AiEmbedding][0]; - - // Should count the 3 items inside response, not just 1 wrapper - expect(outputData.iterations).toBe(1); - expect(outputData.total).toBe(3); - - // Should also apply to per-target counts - expect(outputData.byTarget).toBeDefined(); - assert(outputData.byTarget); - - expect(outputData.byTarget[vectorStoreNode.id]).toBeDefined(); - expect(outputData.byTarget[vectorStoreNode.id].total).toBe(3); - }); - }); - - describe('additionalNodePropertiesById', () => { - it('should return empty object when there are no sticky nodes', () => { - const nodes = ref([]); - const connections = {}; - const workflowObject = createTestWorkflowObject(); - const { additionalNodePropertiesById } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - expect(additionalNodePropertiesById.value).toEqual({}); - }); - - it('should calculate zIndex correctly for a single sticky node', () => { - const nodes = [ - createTestNode({ - id: '1', - type: STICKY_NODE_TYPE, - position: [0, 0], - parameters: { width: 100, height: 100 }, - }), - ]; - const connections = {}; - const workflowObject = createTestWorkflowObject(); - const { additionalNodePropertiesById } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - expect(additionalNodePropertiesById.value[nodes[0].id]).toEqual({ - style: { zIndex: -100 }, - }); - }); - - it('should calculate zIndex correctly for multiple sticky nodes with no overlap', () => { - const nodes = [ - createTestNode({ - id: '1', - type: STICKY_NODE_TYPE, - position: [0, 0], - parameters: { width: 100, height: 100 }, - }), - createTestNode({ - id: '2', - type: STICKY_NODE_TYPE, - position: [200, 200], - parameters: { width: 100, height: 100 }, - }), - ]; - const connections = {}; - const workflowObject = createTestWorkflowObject(); - const { additionalNodePropertiesById } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(additionalNodePropertiesById.value[nodes[0].id]).toEqual({ - style: { zIndex: -100 }, - }); - expect(additionalNodePropertiesById.value[nodes[1].id]).toEqual({ - style: { zIndex: -99 }, - }); - }); - - it('should calculate zIndex correctly for overlapping sticky nodes', () => { - const nodes = [ - createTestNode({ - id: '1', - type: STICKY_NODE_TYPE, - position: [50, 50], - parameters: { width: 100, height: 100 }, - }), - createTestNode({ - id: '2', - type: STICKY_NODE_TYPE, - position: [0, 0], - parameters: { width: 150, height: 150 }, - }), - ]; - const connections = {}; - const workflowObject = createTestWorkflowObject(); - const { additionalNodePropertiesById } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(additionalNodePropertiesById.value[nodes[0].id]).toEqual({ - style: { zIndex: -99 }, - }); - expect(additionalNodePropertiesById.value[nodes[1].id]).toEqual({ - style: { zIndex: -100 }, - }); - }); - - it('calculates zIndex correctly for multiple overlapping sticky nodes', () => { - const nodes = [ - createTestNode({ - id: '1', - type: STICKY_NODE_TYPE, - position: [0, 0], - parameters: { width: 100, height: 100 }, - }), - createTestNode({ - id: '2', - type: STICKY_NODE_TYPE, - position: [25, 25], - parameters: { width: 50, height: 50 }, - }), - createTestNode({ - id: '3', - type: STICKY_NODE_TYPE, - position: [50, 50], - parameters: { width: 100, height: 100 }, - }), - ]; - const connections = {}; - const workflowObject = createTestWorkflowObject(); - const { additionalNodePropertiesById } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(additionalNodePropertiesById.value[nodes[0].id]).toEqual({ - style: { zIndex: -100 }, - }); - expect(additionalNodePropertiesById.value[nodes[1].id]).toEqual({ - style: { zIndex: -98 }, - }); - expect(additionalNodePropertiesById.value[nodes[2].id]).toEqual({ - style: { zIndex: -99 }, - }); - }); - }); - - describe('node issues', () => { - it('should return empty validation and not visible when node has no issues', () => { - const node = createTestNode({ name: 'Test Node' }); - const nodes = [node]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ nodes, connections }); - - const { nodes: mappedNodes } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedNodes.value[0]?.data?.issues).toEqual({ - validation: [], - visible: false, - }); - }); - - it('should mark issues.visible when renderData reports execution errors', () => { - const node = createTestNode({ name: 'Test Node' }); - const nodes = [node]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ nodes, connections }); - const renderData = createRenderDataWithExecutionIssuesByNodeName({ - [node.name]: ['Test error message (Test error description)'], - }); - - const { nodes: mappedNodes } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData, - }); - - expect(mappedNodes.value[0]?.data?.issues).toEqual({ - validation: [], - visible: true, - }); - }); - - it('should expose validation errors from node.issues', () => { - const node = createTestNode({ - name: 'Test Node', - issues: { - typeUnknown: true, - }, - } as Partial); - const nodes = [node]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ nodes, connections }); - - const { nodes: mappedNodes } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedNodes.value[0]?.data?.issues).toEqual({ - validation: ['Node Type "n8n-nodes-base.set" is not known.'], - visible: true, - }); - }); - - it('should mark issues.visible when both validation and execution errors are present', () => { - const node = createTestNode({ - name: 'Test Node', - issues: { - typeUnknown: true, - }, - } as Partial); - const nodes = [node]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ nodes, connections }); - const renderData = createRenderDataWithExecutionIssuesByNodeName({ - [node.name]: ['Execution error (Error description)'], - }); - - const { nodes: mappedNodes } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData, - }); - - expect(mappedNodes.value[0]?.data?.issues).toEqual({ - validation: ['Node Type "n8n-nodes-base.set" is not known.'], - visible: true, - }); - }); - - it('should handle multiple nodes with different issues', () => { - const node1 = createTestNode({ - name: 'Node 1', - issues: { - typeUnknown: true, - }, - } as Partial); - const node2 = createTestNode({ name: 'Node 2' }); - const nodes = [node1, node2]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ nodes, connections }); - const renderData = createRenderDataWithExecutionIssuesByNodeName({ - [node2.name]: ['Execution error (Error description)'], - }); - - const { nodes: mappedNodes } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData, - }); - - expect(mappedNodes.value[0]?.data?.issues).toEqual({ - validation: ['Node Type "n8n-nodes-base.set" is not known.'], - visible: true, - }); - expect(mappedNodes.value[1]?.data?.issues).toEqual({ - validation: [], - visible: true, - }); - }); - }); - - describe('filterOutCanceled helper function', () => { - it('should return null for null input', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const nodes = [createTestNode({ name: 'Node 1' })]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ nodes, connections }); - - workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue(null); - - const { nodes: mappedNodes } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedNodes.value[0]?.data?.runData?.iterations).toEqual(0); - }); - - it('should filter out canceled tasks and return correct iteration count', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const nodes = [createTestNode({ name: 'Node 1' })]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ nodes, connections }); - - workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue([ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'success', - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }]], - }, - }, - { - startTime: 0, - executionTime: 0, - executionIndex: 1, - source: [], - executionStatus: 'canceled', - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }]], - }, - }, - { - startTime: 0, - executionTime: 0, - executionIndex: 2, - source: [], - executionStatus: 'error', - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }]], - }, - }, - ]); - - const { nodes: mappedNodes } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedNodes.value[0]?.data?.runData?.iterations).toEqual(2); - expect(mappedNodes.value[0]?.data?.runData?.visible).toEqual(true); - }); - - it('should return 0 iterations when all tasks are canceled', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const nodes = [createTestNode({ name: 'Node 1' })]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ nodes, connections }); - - workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue([ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'canceled', - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }]], - }, - }, - { - startTime: 0, - executionTime: 0, - executionIndex: 1, - source: [], - executionStatus: 'canceled', - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }]], - }, - }, - ]); - - const { nodes: mappedNodes } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedNodes.value[0]?.data?.runData?.iterations).toEqual(0); - expect(mappedNodes.value[0]?.data?.runData?.visible).toEqual(true); - }); - - it('should return correct count when no canceled tasks', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const nodes = [createTestNode({ name: 'Node 1' })]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ nodes, connections }); - - workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue([ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'success', - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }]], - }, - }, - { - startTime: 0, - executionTime: 0, - executionIndex: 1, - source: [], - executionStatus: 'error', - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }]], - }, - }, - ]); - - const { nodes: mappedNodes } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedNodes.value[0]?.data?.runData?.iterations).toEqual(2); - }); - }); - - describe('nodeExecutionStatusById', () => { - it('should return last execution status when not canceled', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const node = createTestNode({ name: 'Test Node' }); - const nodes = [node]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ nodes, connections }); - - workflowsStore.getWorkflowRunData = { - 'Test Node': [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'success', - }, - ], - }; - - const { nodes: mappedNodes } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedNodes.value[0]?.data?.execution?.status).toEqual('success'); - }); - - it('should return second-to-last status when last execution is canceled and multiple tasks exist', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const node = createTestNode({ name: 'Test Node' }); - const nodes = [node]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ nodes, connections }); - - workflowsStore.getWorkflowRunData = { - 'Test Node': [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'success', - }, - { - startTime: 0, - executionTime: 0, - executionIndex: 1, - source: [], - executionStatus: 'canceled', - }, - ], - }; - - const { nodes: mappedNodes } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedNodes.value[0]?.data?.execution?.status).toEqual('success'); - }); - - it('should return canceled status when only one task exists and it is canceled', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const node = createTestNode({ name: 'Test Node' }); - const nodes = [node]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ nodes, connections }); - - workflowsStore.getWorkflowRunData = { - 'Test Node': [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'canceled', - }, - ], - }; - - const { nodes: mappedNodes } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedNodes.value[0]?.data?.execution?.status).toEqual('canceled'); - }); - - it('should return new status when no tasks exist', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const node = createTestNode({ name: 'Test Node' }); - const nodes = [node]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ nodes, connections }); - - workflowsStore.getWorkflowRunData = {}; - - const { nodes: mappedNodes } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedNodes.value[0]?.data?.execution?.status).toEqual('new'); - }); - }); - - describe('nodeHasIssuesById', () => { - it('should return false when node has no issues or errors', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const node = createTestNode({ name: 'Test Node' }); - const nodes = [node]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ nodes, connections }); - - workflowsStore.getWorkflowRunData = {}; - setPinData({}); - - const { nodeHasIssuesById } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(nodeHasIssuesById.value[node.id]).toBe(false); - }); - - it('should return true when execution status is crashed', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const node = createTestNode({ name: 'Test Node' }); - const nodes = [node]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ nodes, connections }); - - workflowsStore.getWorkflowRunData = { - 'Test Node': [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'crashed', - }, - ], - }; - setPinData({}); - - const { nodeHasIssuesById } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(nodeHasIssuesById.value[node.id]).toBe(true); - }); - - it('should return true when execution status is error', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const node = createTestNode({ name: 'Test Node' }); - const nodes = [node]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ nodes, connections }); - - workflowsStore.getWorkflowRunData = { - 'Test Node': [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'error', - }, - ], - }; - setPinData({}); - - const { nodeHasIssuesById } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(nodeHasIssuesById.value[node.id]).toBe(true); - }); - - it('should return false when node has pinned data regardless of issues', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const node = createTestNode({ - name: 'Test Node', - issues: { - typeUnknown: true, - }, - } as Partial); - const nodes = [node]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ nodes, connections }); - - workflowsStore.getWorkflowRunData = {}; - setPinData({ 'Test Node': [{ json: {} }] }); - - const { nodeHasIssuesById } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(nodeHasIssuesById.value[node.id]).toBe(false); - }); - - it('should return true when node has issues and no pinned data', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const node = createTestNode({ - name: 'Test Node', - issues: { - typeUnknown: true, - }, - } as Partial); - const nodes = [node]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ nodes, connections }); - - workflowsStore.getWorkflowRunData = {}; - setPinData({}); - - const { nodeHasIssuesById } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(nodeHasIssuesById.value[node.id]).toBe(true); - }); - - it('should return true when renderData reports execution errors', () => { - const node = createTestNode({ name: 'Test Node' }); - const nodes = [node]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ nodes, connections }); - const renderData = createRenderDataWithExecutionIssuesByNodeName({ - [node.name]: ['Execution error (Error description)'], - }); - setPinData({}); - - const { nodeHasIssuesById } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData, - }); - - expect(nodeHasIssuesById.value[node.id]).toBe(true); - }); - - it('should handle multiple nodes with different issue states', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const node1 = createTestNode({ - name: 'Node 1', - issues: { - typeUnknown: true, - }, - } as Partial); - const node2 = createTestNode({ name: 'Node 2' }); - const node3 = createTestNode({ name: 'Node 3' }); - const nodes = [node1, node2, node3]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ nodes, connections }); - - workflowsStore.getWorkflowRunData = { - 'Node 2': [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'error', - }, - ], - 'Node 3': [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'success', - }, - ], - }; - setPinData({ 'Node 1': [{ json: {} }] }); - - const { nodeHasIssuesById } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(nodeHasIssuesById.value[node1.id]).toBe(false); // Has issues but also pinned data - expect(nodeHasIssuesById.value[node2.id]).toBe(true); // Has error status - expect(nodeHasIssuesById.value[node3.id]).toBe(false); // No issues - }); - - it('should handle node validation issues', () => { - const node1 = createTestNode({ - name: 'Node 1', - issues: { - parameters: { - formTitle: ['Parameter "Form Title" is required.'], - }, - }, - } as Partial); - const nodes = [node1]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ nodes, connections }); - - const { nodeHasIssuesById } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - expect(nodeHasIssuesById.value[node1.id]).toBe(true); // Has error status - }); - - it('should handle successful executions after errors', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const node1 = createTestNode({ name: 'Node 2' }); - const nodes = [node1]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ nodes, connections }); - - workflowsStore.getWorkflowRunData = { - 'Node 2': [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'error', - }, - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'success', - }, - ], - }; - - const { nodeHasIssuesById } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(nodeHasIssuesById.value[node1.id]).toBe(false); // Last run was successful - }); - }); - }); - - describe('nodeExecutionWaitingForNextById', () => { - it('should be true when already executed node is waiting for next', () => { - const node1 = createTestNode({ - name: 'Node 1', - }); - const node2 = createTestNode({ - name: 'Node 2', - }); - const nodes = [node1, node2]; - const connections = {}; - - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - getExecutingNode().executingNode = []; - getExecutingNode().lastAddedExecutingNode = node1.name; - setWorkflowRunning(true); - - const { nodeExecutionWaitingForNextById } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(nodeExecutionWaitingForNextById.value[node1.id]).toBe(true); - expect(nodeExecutionWaitingForNextById.value[node2.id]).toBe(false); - }); - - it('should be false when workflow is not executing', () => { - const node1 = createTestNode({ - name: 'Node 1', - }); - const node2 = createTestNode({ - name: 'Node 2', - }); - const nodes = [node1, node2]; - const connections = {}; - - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - getExecutingNode().executingNode = []; - getExecutingNode().lastAddedExecutingNode = node1.name; - setWorkflowRunning(false); - - const { nodeExecutionWaitingForNextById } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(nodeExecutionWaitingForNextById.value[node1.id]).toBe(false); - expect(nodeExecutionWaitingForNextById.value[node2.id]).toBe(false); - }); - - it('should be false when there are nodes that are executing', () => { - const node1 = createTestNode({ - name: 'Node 1', - }); - const node2 = createTestNode({ - name: 'Node 2', - }); - const nodes = [node1, node2]; - const connections = {}; - - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - getExecutingNode().executingNode = [node2.name]; - getExecutingNode().lastAddedExecutingNode = node1.name; - setWorkflowRunning(false); - - const { nodeExecutionWaitingForNextById } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(nodeExecutionWaitingForNextById.value[node1.id]).toBe(false); - expect(nodeExecutionWaitingForNextById.value[node2.id]).toBe(false); - }); - }); - - describe('trigger tooltip behavior with pinned data', () => { - it('should show tooltip for trigger node with no pinned data when workflow is running', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const triggerNode = mockNode({ - name: 'Manual Trigger', - type: MANUAL_TRIGGER_NODE_TYPE, - disabled: false, - }); - const nodesList = [triggerNode]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ - nodes: nodesList, - connections, - }); - - setWorkflowRunning(true); - workflowsStore.getWorkflowRunData = {}; - setPinData({}); - - const { nodes: mappedNodes } = useCanvasMapping({ - nodes: ref(nodesList), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - const renderOptions = mappedNodes.value[0]?.data?.render as CanvasNodeDefaultRender; - expect(renderOptions.options.tooltip).toBe('node.waitingForYouToCreateAnEventIn'); - }); - - describe('when the node has a custom eventTriggerDescription', () => { - it('should show tooltip for trigger node with no pinned data when workflow is running', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const triggerNode = mockNode({ - name: 'Form Trigger', - type: FORM_TRIGGER_NODE_TYPE, - disabled: false, - }); - const nodesList = [triggerNode]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ - nodes: nodesList, - connections, - }); - - setWorkflowRunning(true); - workflowsStore.getWorkflowRunData = {}; - setPinData({}); - - const { nodes: mappedNodes } = useCanvasMapping({ - nodes: ref(nodesList), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - const renderOptions = mappedNodes.value[0]?.data?.render as CanvasNodeDefaultRender; - expect(renderOptions.options.tooltip).toBe('n8n-nodes-base.formTrigger'); - }); - }); - - it('should not show tooltip for trigger node with pinned data when workflow is running', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const triggerNode = mockNode({ - name: 'Manual Trigger', - type: MANUAL_TRIGGER_NODE_TYPE, - disabled: false, - }); - const nodesList = [triggerNode]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ - nodes: nodesList, - connections, - }); - - setWorkflowRunning(true); - workflowsStore.getWorkflowRunData = {}; - setPinData({ 'Manual Trigger': [{ json: {} }] }); // Node has pinned data - - const { nodes: mappedNodes } = useCanvasMapping({ - nodes: ref(nodesList), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - const renderOptions = mappedNodes.value[0]?.data?.render as CanvasNodeDefaultRender; - expect(renderOptions.options.tooltip).toBeUndefined(); - }); - - it('should not show tooltip when workflow is not running', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const triggerNode = mockNode({ - name: 'Manual Trigger', - type: MANUAL_TRIGGER_NODE_TYPE, - disabled: false, - }); - const nodesList = [triggerNode]; - const connections = {}; - const workflowObject = createTestWorkflowObject({ - nodes: nodesList, - connections, - }); - - setWorkflowRunning(false); - workflowsStore.getWorkflowRunData = {}; - setPinData({}); - - const { nodes: mappedNodes } = useCanvasMapping({ - nodes: ref(nodesList), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - const renderOptions = mappedNodes.value[0]?.data?.render as CanvasNodeDefaultRender; - expect(renderOptions.options.tooltip).toBeUndefined(); - }); - }); - - describe('connections', () => { - it('should map connections to canvas connections', () => { - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.Main]: [ - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - const source = manualTriggerNode.id; - const sourceHandle = createCanvasConnectionHandleString({ - type: NodeConnectionTypes.Main, - index: 0, - mode: CanvasConnectionMode.Output, - }); - const target = setNode.id; - const targetHandle = createCanvasConnectionHandleString({ - type: NodeConnectionTypes.Main, - index: 0, - mode: CanvasConnectionMode.Input, - }); - const connectionId = createCanvasConnectionId({ - source, - sourceHandle, - target, - targetHandle, - }); - - expect(mappedConnections.value).toEqual([ - { - data: { - source: { - node: manualTriggerNode.name, - index: 0, - type: NodeConnectionTypes.Main, - }, - status: undefined, - target: { - node: setNode.name, - index: 0, - type: NodeConnectionTypes.Main, - }, - }, - id: connectionId, - label: '', - markerEnd: MarkerType.ArrowClosed, - source, - sourceHandle, - target, - targetHandle, - type: 'canvas-edge', - }, + it('leaves the status unset when all source tasks are canceled', () => { + const { allNodes, connections } = makeWorkflow(mainConnections()); + const rd = createEmptyCanvasRenderData(); + setRunData(rd, 'a', [ + { executionStatus: 'canceled' } as ITaskData, + { executionStatus: 'canceled' } as ITaskData, ]); - }); + rd.executionRunDataOutputMapByNodeId.set('a', { main: { '0': { total: 2, iterations: 0 } } }); - it('should map multiple input types to canvas connections', () => { - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.AiTool]: [ - [{ node: setNode.name, type: NodeConnectionTypes.AiTool, index: 0 }], - ], - [NodeConnectionTypes.AiDocument]: [ - [{ node: setNode.name, type: NodeConnectionTypes.AiDocument, index: 1 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), + const { connections: mapped } = useCanvasMapping({ + nodes: ref(allNodes), connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, + renderData: shallowRef(rd), }); - const sourceA = manualTriggerNode.id; - const sourceHandleA = createCanvasConnectionHandleString({ - type: NodeConnectionTypes.AiTool, - index: 0, - mode: CanvasConnectionMode.Output, - }); - const targetA = setNode.id; - const targetHandleA = createCanvasConnectionHandleString({ - type: NodeConnectionTypes.AiTool, - index: 0, - mode: CanvasConnectionMode.Input, - }); - const connectionIdA = createCanvasConnectionId({ - source: sourceA, - sourceHandle: sourceHandleA, - target: targetA, - targetHandle: targetHandleA, - }); - - const sourceB = manualTriggerNode.id; - const sourceHandleB = createCanvasConnectionHandleString({ - type: NodeConnectionTypes.AiDocument, - index: 0, - mode: CanvasConnectionMode.Output, - }); - const targetB = setNode.id; - const targetHandleB = createCanvasConnectionHandleString({ - type: NodeConnectionTypes.AiDocument, - index: 1, - mode: CanvasConnectionMode.Input, - }); - const connectionIdB = createCanvasConnectionId({ - source: sourceB, - sourceHandle: sourceHandleB, - target: targetB, - targetHandle: targetHandleB, - }); - - expect(mappedConnections.value).toEqual([ - { - data: { - source: { - node: manualTriggerNode.name, - index: 0, - type: NodeConnectionTypes.AiTool, - }, - status: undefined, - target: { - node: setNode.name, - index: 0, - type: NodeConnectionTypes.AiTool, - }, - }, - id: connectionIdA, - label: '', - markerEnd: MarkerType.ArrowClosed, - source: sourceA, - sourceHandle: sourceHandleA, - target: targetA, - targetHandle: targetHandleA, - type: 'canvas-edge', - }, - { - data: { - source: { - node: manualTriggerNode.name, - index: 0, - type: NodeConnectionTypes.AiDocument, - }, - status: undefined, - target: { - node: setNode.name, - index: 1, - type: NodeConnectionTypes.AiDocument, - }, - }, - id: connectionIdB, - label: '', - source: sourceB, - sourceHandle: sourceHandleB, - target: targetB, - targetHandle: targetHandleB, - type: 'canvas-edge', - markerEnd: MarkerType.ArrowClosed, - }, - ]); + expect(mapped.value[0].data?.status).toBeUndefined(); }); - describe('connection status with canceled tasks', () => { - it('should not set success status when last task is canceled', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.Main]: [ - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); + it('prioritizes running status over canceled-task handling', () => { + const { allNodes, connections } = makeWorkflow(mainConnections()); + const rd = createEmptyCanvasRenderData(); + rd.executionRunningByNodeId.set( + 'a', + computed(() => true), + ); + setRunData(rd, 'a', [{ executionStatus: 'canceled' } as ITaskData]); - workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { - if (nodeName === manualTriggerNode.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'canceled', - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }]], - }, - }, - ]; - } - return null; - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedConnections.value[0]?.data?.status).toBeUndefined(); + const { connections: mapped } = useCanvasMapping({ + nodes: ref(allNodes), + connections: ref(connections), + renderData: shallowRef(rd), }); - it('should set success status when last task is canceled but previous is success', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.Main]: [ - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { - if (nodeName === manualTriggerNode.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'success', - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }]], - }, - }, - { - startTime: 0, - executionTime: 0, - executionIndex: 1, - source: [], - executionStatus: 'canceled', - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }]], - }, - }, - ]; - } - return null; - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedConnections.value[0]?.data?.status).toEqual('success'); - }); - - it('should handle connection with only canceled tasks', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.Main]: [ - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { - if (nodeName === manualTriggerNode.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'canceled', - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }]], - }, - }, - { - startTime: 0, - executionTime: 0, - executionIndex: 1, - source: [], - executionStatus: 'canceled', - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }]], - }, - }, - ]; - } - return null; - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedConnections.value[0]?.data?.status).toBeUndefined(); - }); - - it('should prioritize running status over canceled task handling', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.Main]: [ - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - getExecutingNode().isNodeExecuting = vi.fn().mockImplementation((nodeName: string) => { - return nodeName === manualTriggerNode.name; - }); - - workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { - if (nodeName === manualTriggerNode.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'canceled', - data: { - [NodeConnectionTypes.Main]: [[]], - }, - }, - ]; - } - return null; - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedConnections.value[0]?.data?.status).toEqual('running'); - }); - }); - - describe('getConnectionLabel', () => { - it('should return undefined when source node is not found', () => { - const [, setNode] = mockNodes.slice(0, 2); - const nodes = [setNode]; // manualTriggerNode is missing - const connections = { - 'Non-existent Node': { - [NodeConnectionTypes.Main]: [ - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedConnections.value[0]?.label).toBe(undefined); - }); - - it('should return pinned data count label when node has pinned data', () => { - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.Main]: [ - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - // Mock pinned data with 3 items - setPinData({ - [manualTriggerNode.name]: [{ json: { id: 1 } }, { json: { id: 2 } }, { json: { id: 3 } }], - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedConnections.value[0]?.label).toBe('3 items'); - }); - - it('should return singular item label when pinned data has 1 item', () => { - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.Main]: [ - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - // Mock pinned data with 1 item - setPinData({ - [manualTriggerNode.name]: [{ json: { id: 1 } }], - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedConnections.value[0]?.label).toBe('1 item'); - }); - - it('should return empty string when pinned data is empty array', () => { - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.Main]: [ - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - // Mock pinned data with empty array - setPinData({ - [manualTriggerNode.name]: [], - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedConnections.value[0]?.label).toBe(''); - }); - - it('should return execution data count when node has run data and no pinned data', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.Main]: [ - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - setPinData({}); - workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { - if (nodeName === manualTriggerNode.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }, { json: {} }, { json: {} }]], - }, - }, - ]; - } - return null; - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedConnections.value[0]?.label).toBe('3 items'); - }); - - it('should return singular item label for execution data with 1 item', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.Main]: [ - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - setPinData({}); - workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { - if (nodeName === manualTriggerNode.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }]], - }, - }, - ]; - } - return null; - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedConnections.value[0]?.label).toBe('1 item'); - }); - - it('should return empty string when execution data total is 0', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.Main]: [ - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - setPinData({}); - workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { - if (nodeName === manualTriggerNode.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - data: { - [NodeConnectionTypes.Main]: [[]], - }, - }, - ]; - } - return null; - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedConnections.value[0]?.label).toBe(''); - }); - - it('should handle multiple iterations with single execution data label', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.Main]: [ - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - setPinData({}); - workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { - if (nodeName === manualTriggerNode.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }, { json: {} }]], - }, - }, - ]; - } - return null; - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedConnections.value[0]?.label).toBe('2 items'); - }); - - it('should handle multiple iterations with total items label', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.Main]: [ - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - setPinData({}); - workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { - if (nodeName === manualTriggerNode.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }]], - }, - }, - { - startTime: 0, - executionTime: 0, - executionIndex: 1, - source: [], - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }, { json: {} }]], - }, - }, - ]; - } - return null; - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedConnections.value[0]?.label).toBe('3 items total'); - }); - - it('should handle different connection types correctly', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.AiTool]: [ - [{ node: setNode.name, type: NodeConnectionTypes.AiTool, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - setPinData({}); - workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { - if (nodeName === manualTriggerNode.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [{ previousNode: setNode.name }], - data: { - [NodeConnectionTypes.AiTool]: [[{ json: {} }, { json: {} }]], - }, - }, - ]; - } - // Add execution data for target node so connection shows as executed - if (nodeName === setNode.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'success', - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }]], - }, - }, - ]; - } - return null; - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedConnections.value[0]?.label).toBe('2 items'); - }); - - it('should prioritize pinned data over execution data', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.Main]: [ - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - // Mock both pinned data and execution data - setPinData({ - [manualTriggerNode.name]: [{ json: { id: 1 } }, { json: { id: 2 } }], - }); - workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { - if (nodeName === manualTriggerNode.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }, { json: {} }, { json: {} }]], - }, - }, - ]; - } - return null; - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - // Should show pinned data count (2 items), not execution data count (3 items) - expect(mappedConnections.value[0]?.label).toBe('2 items'); - }); - - it('should return empty string when no data is available', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.Main]: [ - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - setPinData({}); - workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue(null); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedConnections.value[0]?.label).toBe(''); - }); - - it('should handle connection with specific output index', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.Main]: [ - [], // index 0 - empty - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], // index 1 - connected - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - setPinData({}); - workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { - if (nodeName === manualTriggerNode.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - data: { - [NodeConnectionTypes.Main]: [ - [{ json: {} }], // index 0 - 1 item - [{ json: {} }, { json: {} }, { json: {} }], // index 1 - 3 items - ], - }, - }, - ]; - } - return null; - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - // Should show the count for output index 1 (3 items), not index 0 (1 item) - expect(mappedConnections.value[0]?.label).toBe('3 items'); - }); - }); - - describe('getConnectionData', () => { - it('should return running status when source node is executing and has no run data', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.Main]: [ - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - getExecutingNode().isNodeExecuting = vi.fn().mockImplementation((nodeName: string) => { - return nodeName === manualTriggerNode.name; - }); - workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue(null); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedConnections.value[0]?.data?.status).toEqual('running'); - }); - - it('should return pinned status when source has both pinned data and execution data', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.Main]: [ - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - setPinData({ - [manualTriggerNode.name]: [{ json: {} }], - }); - workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { - if (nodeName === manualTriggerNode.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }]], - }, - }, - ]; - } - return null; - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedConnections.value[0]?.data?.status).toEqual('pinned'); - }); - - it('should return error status when source node has issues', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const node = createTestNode({ - name: 'Test Node', - issues: { typeUnknown: true }, - } as Partial); - const setNode = createTestNode({ name: 'Set Node' }); - const nodes = [node, setNode]; - const connections = { - [node.name]: { - [NodeConnectionTypes.Main]: [ - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ nodes, connections }); - - workflowsStore.getWorkflowRunData = {}; - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedConnections.value[0]?.data?.status).toEqual('error'); - }); - - it('should return success status when node has run data and no issues', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.Main]: [ - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { - if (nodeName === manualTriggerNode.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'success', - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }]], - }, - }, - ]; - } - return null; - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedConnections.value[0]?.data?.status).toEqual('success'); - }); - - it('should return undefined status when no conditions are met', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.Main]: [ - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue(null); - setPinData({}); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedConnections.value[0]?.data?.status).toBeUndefined(); - }); - - it('should include maxConnections when ports have connection limits', () => { - const nodeTypesStore = mockedStore(useNodeTypesStore); - const manualTriggerNode = mockNode({ - name: 'Manual Trigger', - type: MANUAL_TRIGGER_NODE_TYPE, - }); - const setNode = mockNode({ - name: 'Set', - type: SET_NODE_TYPE, - }); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.Main]: [ - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - - // Mock node type with maxConnections - const nodeTypeWithMaxConnections = mockNodeTypeDescription({ - name: SET_NODE_TYPE, - inputs: [ - { - type: NodeConnectionTypes.Main, - maxConnections: 1, - }, - ], - }); - - nodeTypesStore.nodeTypes = { - [MANUAL_TRIGGER_NODE_TYPE]: { - 1: mockNodeTypeDescription({ - name: MANUAL_TRIGGER_NODE_TYPE, - }), - }, - [SET_NODE_TYPE]: { - 1: nodeTypeWithMaxConnections, - }, - }; - - setupRenderNodes([ - { - id: manualTriggerNode.id, - inputs: [{ type: NodeConnectionTypes.Main, index: 0 }], - outputs: [{ type: NodeConnectionTypes.Main, index: 0 }], - }, - { - id: setNode.id, - inputs: [{ type: NodeConnectionTypes.Main, index: 0, maxConnections: 1 }], - outputs: [{ type: NodeConnectionTypes.Main, index: 0 }], - }, - ]); - - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: testRenderData, - }); - - expect(mappedConnections.value[0]?.data?.maxConnections).toEqual(1); - }); - - it('should use minimum maxConnections when multiple ports have limits', () => { - const manualTriggerNode = mockNode({ - name: 'Manual Trigger', - type: MANUAL_TRIGGER_NODE_TYPE, - }); - const setNode = mockNode({ - name: 'Set', - type: SET_NODE_TYPE, - }); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.Main]: [ - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - - setupRenderNodes([ - { - id: manualTriggerNode.id, - inputs: [{ type: NodeConnectionTypes.Main, index: 0 }], - outputs: [{ type: NodeConnectionTypes.Main, index: 0, maxConnections: 3 }], - }, - { - id: setNode.id, - inputs: [{ type: NodeConnectionTypes.Main, index: 0, maxConnections: 2 }], - outputs: [{ type: NodeConnectionTypes.Main, index: 0 }], - }, - ]); - - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: testRenderData, - }); - - expect(mappedConnections.value[0]?.data?.maxConnections).toEqual(2); - }); - - it('should not include maxConnections when no limits are set', () => { - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.Main]: [ - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedConnections.value[0]?.data?.maxConnections).toBeUndefined(); - }); - - it('should preserve existing connection data', () => { - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.Main]: [ - [ - { - node: setNode.name, - type: NodeConnectionTypes.Main, - index: 0, - // Add custom data that should be preserved - customField: 'customValue', - }, - ], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedConnections.value[0]?.data?.source.node).toEqual(manualTriggerNode.name); - expect(mappedConnections.value[0]?.data?.target.node).toEqual(setNode.name); - }); - - it('should handle connections with different output indices correctly', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.Main]: [ - [], // index 0 - empty - [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], // index 1 - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { - if (nodeName === manualTriggerNode.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'success', - data: { - [NodeConnectionTypes.Main]: [ - [{ json: {} }], // index 0 - [{ json: {} }, { json: {} }], // index 1 - ], - }, - }, - ]; - } - return null; - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedConnections.value[0]?.data?.status).toEqual('success'); - }); - - it('should handle different connection types', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); - const nodes = [manualTriggerNode, setNode]; - const connections = { - [manualTriggerNode.name]: { - [NodeConnectionTypes.AiTool]: [ - [{ node: setNode.name, type: NodeConnectionTypes.AiTool, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { - if (nodeName === manualTriggerNode.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'success', - data: { - [NodeConnectionTypes.AiTool]: [[{ json: {} }]], - }, - }, - ]; - } - // Add execution data for target node so connection shows as executed - if (nodeName === setNode.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [{ previousNode: manualTriggerNode.name }], - executionStatus: 'success', - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }]], - }, - }, - ]; - } - return null; - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - expect(mappedConnections.value[0]?.data?.status).toEqual('success'); - }); - - it('should not mark non-main connections as executed when only source has run data', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const embeddingNode = createTestNode({ name: 'Embeddings OpenAI' }); - const vectorStoreNode = createTestNode({ name: 'Vector Store' }); - const nodes = [embeddingNode, vectorStoreNode]; - const connections = { - [embeddingNode.name]: { - [NodeConnectionTypes.AiEmbedding]: [ - [{ node: vectorStoreNode.name, type: NodeConnectionTypes.AiEmbedding, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - // Only source node has execution data, target does not - workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { - if (nodeName === embeddingNode.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'success', - data: { - [NodeConnectionTypes.AiEmbedding]: [[{ json: {} }]], - }, - }, - ]; - } - return null; - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - // Non-main connection should not be marked as success when target hasn't executed - expect(mappedConnections.value[0]?.data?.status).toBeUndefined(); - expect(mappedConnections.value[0]?.label).toBe(''); - }); - - it('should mark non-main connections as executed when both source and target have run data', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const embeddingNode = createTestNode({ name: 'Embeddings OpenAI' }); - const vectorStoreNode = createTestNode({ name: 'Vector Store' }); - const nodes = [embeddingNode, vectorStoreNode]; - const connections = { - [embeddingNode.name]: { - [NodeConnectionTypes.AiEmbedding]: [ - [{ node: vectorStoreNode.name, type: NodeConnectionTypes.AiEmbedding, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - // Both source and target have execution data - workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { - if (nodeName === embeddingNode.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [{ previousNode: vectorStoreNode.name }], - executionStatus: 'success', - data: { - [NodeConnectionTypes.AiEmbedding]: [[{ json: {} }, { json: {} }]], - }, - }, - ]; - } - if (nodeName === vectorStoreNode.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'success', - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }]], - }, - }, - ]; - } - return null; - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - // Non-main connection should be marked as success when both source and target have executed - expect(mappedConnections.value[0]?.data?.status).toEqual('success'); - expect(mappedConnections.value[0]?.label).toBe('2 items'); - }); - - it('should show per-target counts when multiple agents share a model', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const modelNode = createTestNode({ name: 'OpenAI Chat Model' }); - const agent1Node = createTestNode({ name: 'AI Agent 1' }); - const agent2Node = createTestNode({ name: 'AI Agent 2' }); - const nodes = [modelNode, agent1Node, agent2Node]; - const connections = { - [modelNode.name]: { - [NodeConnectionTypes.AiLanguageModel]: [ - [ - { node: agent1Node.name, type: NodeConnectionTypes.AiLanguageModel, index: 0 }, - { node: agent2Node.name, type: NodeConnectionTypes.AiLanguageModel, index: 0 }, - ], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - // Model node has execution data with source tracking - workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { - if (nodeName === modelNode.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [{ previousNode: agent1Node.name }], - executionStatus: 'success', - data: { - [NodeConnectionTypes.AiLanguageModel]: [[{ json: {} }, { json: {} }]], - }, - }, - { - startTime: 0, - executionTime: 0, - executionIndex: 1, - source: [{ previousNode: agent2Node.name }], - executionStatus: 'success', - data: { - [NodeConnectionTypes.AiLanguageModel]: [[{ json: {} }]], - }, - }, - ]; - } - if (nodeName === agent1Node.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'success', - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }]], - }, - }, - ]; - } - if (nodeName === agent2Node.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'success', - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }]], - }, - }, - ]; - } - return null; - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - // Should have two connections from the model node - expect(mappedConnections.value).toHaveLength(2); - - // Find connections for each agent - const agent1Connection = mappedConnections.value.find((c) => c.target === agent1Node.id); - const agent2Connection = mappedConnections.value.find((c) => c.target === agent2Node.id); - - // Each connection should show its specific count - expect(agent1Connection?.label).toBe('2 items'); - expect(agent2Connection?.label).toBe('1 item'); - expect(agent1Connection?.data?.status).toEqual('success'); - expect(agent2Connection?.data?.status).toEqual('success'); - }); - - it('should count items inside response field for non-main connections', () => { - const workflowsStore = mockedStore(useWorkflowsStore); - const embeddingNode = createTestNode({ name: 'Embeddings OpenAI' }); - const vectorStoreNode = createTestNode({ name: 'Vector Store' }); - const nodes = [embeddingNode, vectorStoreNode]; - const connections = { - [embeddingNode.name]: { - [NodeConnectionTypes.AiEmbedding]: [ - [{ node: vectorStoreNode.name, type: NodeConnectionTypes.AiEmbedding, index: 0 }], - ], - }, - }; - const workflowObject = createTestWorkflowObject({ - nodes, - connections, - }); - - // Embedding node returns data with response array containing 6 items - workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { - if (nodeName === embeddingNode.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [{ previousNode: vectorStoreNode.name }], - executionStatus: 'success', - data: { - [NodeConnectionTypes.AiEmbedding]: [ - [ - { - json: { - response: [ - { embedding: [0.1, 0.2] }, - { embedding: [0.3, 0.4] }, - { embedding: [0.5, 0.6] }, - { embedding: [0.7, 0.8] }, - { embedding: [0.9, 1.0] }, - { embedding: [1.1, 1.2] }, - ], - }, - }, - ], - ], - }, - }, - ]; - } - if (nodeName === vectorStoreNode.name) { - return [ - { - startTime: 0, - executionTime: 0, - executionIndex: 0, - source: [], - executionStatus: 'success', - data: { - [NodeConnectionTypes.Main]: [[{ json: {} }]], - }, - }, - ]; - } - return null; - }); - - const { connections: mappedConnections } = useCanvasMapping({ - nodes: ref(nodes), - connections: ref(connections), - workflowObject: ref(workflowObject) as Ref, - renderData: emptyRenderData, - }); - - // Should count the 6 items inside response, not just 1 wrapper object - expect(mappedConnections.value[0]?.data?.status).toEqual('success'); - expect(mappedConnections.value[0]?.label).toBe('6 items'); - }); + expect(mapped.value[0].data?.status).toBe('running'); }); }); }); diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.ts index 892ed0851f3..40a9a571e13 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.ts @@ -4,566 +4,51 @@ */ import { useI18n } from '@n8n/i18n'; -import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; -import { useWorkflowsStore } from '@/app/stores/workflows.store'; -import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store'; -import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store'; import type { CanvasRenderData } from '../canvas.utils'; import type { Ref } from 'vue'; -import { ref, computed } from 'vue'; +import { computed, ref } from 'vue'; import type { - BoundingBox, CanvasConnection, CanvasConnectionData, CanvasNode, - CanvasNodeAddNodesRender, - CanvasNodeChoicePromptRender, CanvasNodeData, - CanvasNodeDefaultRender, - CanvasNodeStickyNoteRender, - ExecutionOutputMap, } from '../canvas.types'; import { CanvasConnectionMode, CanvasNodeRenderType } from '../canvas.types'; import { - checkOverlap, computeNodeDisplaySize, mapLegacyConnectionsToCanvasConnections, parseCanvasConnectionHandleString, } from '../canvas.utils'; -import type { - ExecutionStatus, - ExecutionSummary, - IConnections, - INodeExecutionData, - INodeTypeDescription, - ITaskData, -} from 'n8n-workflow'; -import { NodeConnectionTypes, SEND_AND_WAIT_OPERATION, WAIT_INDEFINITELY } from 'n8n-workflow'; +import type { IConnections, ITaskData } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import type { INodeUi } from '@/Interface'; -import { - CANVAS_EXECUTION_DATA_THROTTLE_DURATION, - CUSTOM_API_CALL_KEY, - FORM_NODE_TYPE, - SIMULATE_NODE_TYPE, - SIMULATE_TRIGGER_NODE_TYPE, - STICKY_NODE_TYPE, - WAIT_NODE_TYPE, -} from '@/app/constants'; import { MarkerType } from '@vue-flow/core'; -import { useNodeHelpers } from '@/app/composables/useNodeHelpers'; -import { getTriggerNodeServiceName } from '@/app/utils/nodeTypesUtils'; -import { useNodeDirtiness } from '@/app/composables/useNodeDirtiness'; -import { getNodeIconSource } from '@/app/utils/nodeIcon'; import * as workflowUtils from 'n8n-workflow/common'; -import { throttledWatch } from '@vueuse/core'; -import type { WorkflowObjectAccessors } from '@/app/types'; +/** + * Maps workflow nodes and connections into the vue-flow canvas shape. + * + * All per-node-id projections (subtitle, validation errors, execution status, + * render type, sticky-note z-index, etc.) live on `renderData`, produced by + * `useWorkflowDocumentRenderData`. This composable is the final glue layer β€” + * it reads from `renderData` and `connections` to assemble `CanvasNode` and + * `CanvasConnection` objects. + */ export function useCanvasMapping({ nodes, connections, - workflowObject, renderData, isExperimentalNdvActive = ref(false), }: { nodes: Ref; connections: Ref; - workflowObject: Ref; renderData: Ref; isExperimentalNdvActive?: Ref; }) { const i18n = useI18n(); - const workflowsStore = useWorkflowsStore(); - const workflowDocumentStore = injectWorkflowDocumentStore(); - const workflowExecutionStateStore = computed(() => - useWorkflowExecutionStateStore(workflowDocumentStore.value.documentId), - ); - const nodeTypesStore = useNodeTypesStore(); - const nodeHelpers = useNodeHelpers(); - const { dirtinessByName } = useNodeDirtiness(); - - 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 createChoicePromptRenderType(): CanvasNodeChoicePromptRender { - return { - type: CanvasNodeRenderType.ChoicePrompt, - options: {}, - }; - } - - function createDefaultNodeRenderType(node: INodeUi): CanvasNodeDefaultRender { - const nodeType = nodeTypeDescriptionByNodeId.value[node.id]; - const source = simulatedNodeTypeDescriptionByNodeId.value[node.id] ?? nodeType ?? node.type; - const icon = getNodeIconSource( - source, - node, - workflowDocumentStore.value.getExpressionHandler(), - ); - - return { - type: CanvasNodeRenderType.Default, - options: { - trigger: isTriggerNodeById.value[node.id], - configuration: nodeTypesStore.isConfigNode(workflowObject.value, node, node.type), - configurable: nodeTypesStore.isConfigurableNode( - workflowObject.value, - node, - node.type, - node.typeVersion, - ), - tooltip: nodeTooltipById.value[node.id], - dirtiness: dirtinessByName.value[node.name], - icon, - placeholder: node.placeholder, - }, - }; - } - - 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; - case `${CanvasNodeRenderType.ChoicePrompt}`: - acc[node.id] = createChoicePromptRenderType(); - break; - default: - acc[node.id] = createDefaultNodeRenderType(node); - } - - return acc; - }, {}) ?? {}, - ); - - const nodeTypeDescriptionByNodeId = computed(() => - nodes.value.reduce>((acc, node) => { - acc[node.id] = - nodeTypesStore.getNodeType(node.type, node.typeVersion) ?? - nodeTypesStore.communityNodeType(node.type)?.nodeDescription ?? - null; - return acc; - }, {}), - ); - - const isTriggerNodeById = computed(() => - nodes.value.reduce>((acc, node) => { - acc[node.id] = nodeTypesStore.isTriggerNode(node.type); - return acc; - }, {}), - ); - - const nodeSubtitleById = computed(() => { - return nodes.value.reduce>((acc, node) => { - try { - const nodeTypeDescription = nodeTypeDescriptionByNodeId.value[node.id]; - 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 nodePinnedDataById = computed(() => - nodes.value.reduce>((acc, node) => { - acc[node.id] = workflowDocumentStore.value.getNodePinData(node.name); - return acc; - }, {}), - ); - - const nodeTooltipById = computed(() => { - if (!workflowExecutionStateStore.value.isWorkflowRunning) { - return {}; - } - - const activeTriggerNodeCount = nodes.value.filter( - (node) => isTriggerNodeById.value[node.id] && !node.disabled, - ).length; - const triggerNodeName = workflowsStore.getWorkflowExecution?.triggerNode; - - // For workflows with multiple active trigger nodes, we show a tooltip only when - // trigger node name is known - if (triggerNodeName === undefined && activeTriggerNodeCount !== 1) { - return {}; - } - - return nodes.value.reduce>((acc, node) => { - const nodeTypeDescription = nodeTypeDescriptionByNodeId.value[node.id]; - if (nodeTypeDescription && isTriggerNodeById.value[node.id]) { - if ( - !!node.disabled || - (triggerNodeName !== undefined && triggerNodeName !== node.name) || - !['new', 'unknown', 'waiting'].includes(nodeExecutionStatusById.value[node.id]) || - nodePinnedDataById.value[node.id] - ) { - return acc; - } - - if (typeof nodeTypeDescription.eventTriggerDescription === 'string') { - const nodeName = i18n.shortNodeType(nodeTypeDescription.name); - const { eventTriggerDescription } = nodeTypeDescription; - acc[node.id] = i18n - .nodeText(nodeTypeDescription.name) - .eventTriggerDescription(nodeName, eventTriggerDescription ?? ''); - } else { - acc[node.id] = i18n.baseText('node.waitingForYouToCreateAnEventIn', { - interpolate: { - nodeType: nodeTypeDescription ? getTriggerNodeServiceName(nodeTypeDescription) : '', - }, - }); - } - } - - return acc; - }, {}); - }); - - const nodeExecutionRunningById = computed(() => - nodes.value.reduce>((acc, node) => { - acc[node.id] = workflowExecutionStateStore.value.executingNode.isNodeExecuting(node.name); - return acc; - }, {}), - ); - - const nodeExecutionWaitingForNextById = computed(() => - nodes.value.reduce>((acc, node) => { - acc[node.id] = - node.name === workflowExecutionStateStore.value.executingNode.lastAddedExecutingNode && - workflowExecutionStateStore.value.executingNode.executingNode.length === 0 && - workflowExecutionStateStore.value.isWorkflowRunning; - - return acc; - }, {}), - ); - - const nodeExecutionStatusById = computed(() => - nodes.value.reduce>((acc, node) => { - const tasks = workflowsStore.getWorkflowRunData?.[node.name] ?? []; - - let lastExecutionStatus = tasks.at(-1)?.executionStatus; - if (tasks.length > 1 && lastExecutionStatus === 'canceled') { - lastExecutionStatus = tasks.at(-2)?.executionStatus; - } - acc[node.id] = lastExecutionStatus ?? 'new'; - return acc; - }, {}), - ); - - const nodeExecutionRunDataById = computed(() => - nodes.value.reduce>((acc, node) => { - acc[node.id] = workflowsStore.getWorkflowResultDataByNodeName(node.name); - return acc; - }, {}), - ); - - // Create a map for O(1) node lookups by name - const nodesByName = computed(() => new Map(nodes.value.map((n) => [n.name, n]))); - - const nodeExecutionRunDataOutputMapById = ref>({}); - - throttledWatch( - () => workflowsStore.workflowExecutionResultDataLastUpdate, - () => { - nodeExecutionRunDataOutputMapById.value = Object.keys(nodeExecutionRunDataById.value).reduce< - Record - >((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, - ...(connectionType !== NodeConnectionTypes.Main ? { byTarget: {} } : {}), - }; - // For non-main connections, check if items are wrapped in a response field - // (common for AI nodes like embeddings, tools, etc.) - // Note: We check only the first item assuming uniform structure across all items - let itemCount = connectionTypeOutputIndexData.length; - if ( - connectionType !== NodeConnectionTypes.Main && - connectionTypeOutputIndexData.length > 0 - ) { - const firstItem = connectionTypeOutputIndexData[0]; - // AI nodes typically wrap all items uniformly in response field - if ( - firstItem?.json && - typeof firstItem.json === 'object' && - 'response' in firstItem.json && - Array.isArray(firstItem.json.response) - ) { - // Use response array length for all items (assuming uniform structure) - itemCount = firstItem.json.response.length; - } - } - - if (runIteration.executionStatus !== 'canceled') { - acc[nodeId][connectionType][outputIndex].iterations += 1; - } - acc[nodeId][connectionType][outputIndex].total += itemCount; - - // For non-main connections, track per-target execution counts - if (connectionType !== NodeConnectionTypes.Main) { - const callingNodeName = runIteration.source?.[0]?.previousNode; - if (callingNodeName) { - const callingNode = nodesByName.value.get(callingNodeName); - if (callingNode) { - const targetId = callingNode.id; - const outputEntry = acc[nodeId][connectionType][outputIndex]; - - if (outputEntry.byTarget) { - if (!outputEntry.byTarget[targetId]) { - outputEntry.byTarget[targetId] = { - total: 0, - iterations: 0, - }; - } - - if (runIteration.executionStatus !== 'canceled') { - outputEntry.byTarget[targetId].iterations += 1; - } - outputEntry.byTarget[targetId].total += itemCount; - } - } - } - } - } - } - } - - return acc; - }, {}); - }, - { throttle: CANVAS_EXECUTION_DATA_THROTTLE_DURATION, immediate: true }, - ); - - const nodeValidationErrorsById = computed(() => - nodes.value.reduce>((acc, node) => { - const validationErrors: string[] = []; - - if (node?.issues !== undefined) { - validationErrors.push(...nodeHelpers.nodeIssuesToString(node.issues, node)); - } - - acc[node.id] = validationErrors; - - return acc; - }, {}), - ); - - const nodeHasIssuesById = computed(() => - nodes.value.reduce>((acc, node) => { - const hasExecutionErrors = - (renderData.value.executionIssuesByNodeName.get(node.name)?.value?.length ?? 0) > 0; - const hasValidationErrors = nodeValidationErrorsById.value[node.id]?.length > 0; - - if (['crashed', 'error'].includes(nodeExecutionStatusById.value[node.id])) { - acc[node.id] = true; - } else if (nodePinnedDataById.value[node.id]) { - acc[node.id] = false; - } else if (hasValidationErrors) { - acc[node.id] = true; - } else if (hasExecutionErrors) { - acc[node.id] = true; - } else { - const tasks = workflowsStore.getWorkflowRunData?.[node.name] ?? []; - acc[node.id] = Boolean(tasks.at(-1)?.error); - } - - 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; - } - - if (node?.parameters.operation === SEND_AND_WAIT_OPERATION) { - acc[node.id] = i18n.baseText('node.theNodeIsWaitingUserInput'); - return acc; - } - - if (node?.type === FORM_NODE_TYPE) { - acc[node.id] = i18n.baseText('node.theNodeIsWaitingFormCall'); - return acc; - } - - const waitDate = new Date(workflowExecution.waitTill); - - if (waitDate.getTime() === WAIT_INDEFINITELY.getTime()) { - acc[node.id] = i18n.baseText( - 'node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall', - ); - - return acc; - } - - 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 simulatedNodeTypeDescriptionByNodeId = computed(() => { - return nodes.value.reduce>((acc, node) => { - if ([SIMULATE_NODE_TYPE, SIMULATE_TRIGGER_NODE_TYPE].includes(node.type)) { - const icon = node.parameters?.icon as string; - const iconValue = workflowObject.value.expression.getSimpleParameterValue( - node, - icon, - 'internal', - {}, - ); - - if (iconValue && typeof iconValue === 'string') { - acc[node.id] = nodeTypesStore.getNodeType(iconValue); - } - } - - return acc; - }, {}); - }); function filterOutCanceled(tasks: ITaskData[] | null): ITaskData[] | null { - if (!tasks) { - return null; - } - + if (!tasks) return null; return tasks.filter((task) => task.executionStatus !== 'canceled'); } @@ -571,17 +56,18 @@ export function useCanvasMapping({ // they wrap each node's actual rendered size. Sticky notes are omitted β€” // their own width/height parameters are read by the group mapper directly. const nodeDisplaySizeById = computed(() => { + const rd = renderData.value; const dimensionsById: Record = {}; for (const node of nodes.value) { - const render = renderTypeByNodeId.value[node.id]; + const render = rd.renderTypeByNodeId.get(node.id)?.value; if (render?.type !== CanvasNodeRenderType.Default) continue; dimensionsById[node.id] = computeNodeDisplaySize( node.id, render.options, - renderData.value, + rd, isExperimentalNdvActive.value, ); } @@ -592,15 +78,19 @@ export function useCanvasMapping({ const connectionsBySourceNode = connections.value; const connectionsByDestinationNode = workflowUtils.mapConnectionsByDestination(connectionsBySourceNode); + const rd = renderData.value; + const additionalProperties = rd.additionalPropertiesByNodeId.value; return nodes.value.map((node) => { const outputConnections = connectionsBySourceNode[node.name] ?? {}; const inputConnections = connectionsByDestinationNode[node.name] ?? {}; + const runData = rd.executionRunDataByNodeId.get(node.id)?.value ?? null; + const data: CanvasNodeData = { id: node.id, name: node.name, - subtitle: nodeSubtitleById.value[node.id] ?? '', + subtitle: rd.subtitleByNodeId.get(node.id)?.value ?? '', type: node.type, typeVersion: node.typeVersion, disabled: node.disabled, @@ -609,21 +99,23 @@ export function useCanvasMapping({ [CanvasConnectionMode.Output]: outputConnections, }, issues: { - validation: nodeValidationErrorsById.value[node.id], - visible: nodeHasIssuesById.value[node.id], + validation: rd.validationErrorsByNodeId.get(node.id)?.value ?? [], + visible: rd.hasIssuesByNodeId.get(node.id)?.value ?? false, }, execution: { - status: nodeExecutionStatusById.value[node.id], - waiting: nodeExecutionWaitingById.value[node.id], - waitingForNext: nodeExecutionWaitingForNextById.value[node.id], - running: nodeExecutionRunningById.value[node.id], + status: rd.executionStatusByNodeId.get(node.id)?.value, + waiting: rd.executionWaitingByNodeId.get(node.id)?.value, + waitingForNext: rd.executionWaitingForNextByNodeId.get(node.id)?.value ?? false, + running: rd.executionRunningByNodeId.get(node.id)?.value ?? false, }, runData: { - outputMap: nodeExecutionRunDataOutputMapById.value[node.id], - iterations: filterOutCanceled(nodeExecutionRunDataById.value[node.id])?.length ?? 0, - visible: !!nodeExecutionRunDataById.value[node.id], + outputMap: rd.executionRunDataOutputMapByNodeId.get(node.id), + iterations: filterOutCanceled(runData)?.length ?? 0, + visible: !!runData, }, - render: renderTypeByNodeId.value[node.id] ?? { type: 'default', options: {} }, + render: + rd.renderTypeByNodeId.get(node.id)?.value ?? + ({ type: node.type, options: {} } as CanvasNodeData['render']), }; return { @@ -632,7 +124,7 @@ export function useCanvasMapping({ type: 'canvas-node', position: { x: node.position[0], y: node.position[1] }, data, - ...additionalNodePropertiesById.value[node.id], + ...additionalProperties[node.id], draggable: node.draggable, }; }); @@ -640,63 +132,57 @@ export function useCanvasMapping({ 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, - markerEnd: MarkerType.ArrowClosed, - }; - }, + (connection) => ({ + ...connection, + data: getConnectionData(connection), + type: 'canvas-edge', + label: getConnectionLabel(connection), + markerEnd: MarkerType.ArrowClosed, + }), ); }); function getConnectionData(connection: CanvasConnection): CanvasConnectionData { + const rd = renderData.value; const { type, index } = parseCanvasConnectionHandleString(connection.sourceHandle); - const runData = nodeExecutionRunDataOutputMapById.value[connection.source]?.[type]?.[index]; + const outputMap = rd.executionRunDataOutputMapByNodeId.get(connection.source); + const runData = outputMap?.[type]?.[index]; const runDataTotal = runData?.total ?? 0; - const sourceTasks = nodeExecutionRunDataById.value[connection.source] ?? []; + const sourceTasks = rd.executionRunDataByNodeId.get(connection.source)?.value ?? []; let lastSourceTask: ITaskData | undefined = sourceTasks[sourceTasks.length - 1]; if (lastSourceTask?.executionStatus === 'canceled' && sourceTasks.length > 1) { lastSourceTask = sourceTasks[sourceTasks.length - 2]; } + const sourcePinned = rd.pinnedDataByNodeId.get(connection.source)?.value; + const sourceRunData = rd.executionRunDataByNodeId.get(connection.source)?.value; + const targetRunData = rd.executionRunDataByNodeId.get(connection.target)?.value; + const sourceRunning = rd.executionRunningByNodeId.get(connection.source)?.value ?? false; + const sourceHasIssues = rd.hasIssuesByNodeId.get(connection.source)?.value ?? false; + let status: CanvasConnectionData['status']; - if (nodeExecutionRunningById.value[connection.source] && runDataTotal === 0) { + if (sourceRunning && runDataTotal === 0) { status = 'running'; - } else if ( - nodePinnedDataById.value[connection.source] && - nodeExecutionRunDataById.value[connection.source] - ) { + } else if (sourcePinned && sourceRunData) { status = 'pinned'; - } else if (nodeHasIssuesById.value[connection.source]) { + } else if (sourceHasIssues) { status = 'error'; } else if (runDataTotal > 0 && lastSourceTask?.executionStatus !== 'canceled') { - // For non-main connections (model, memory, tool, etc.), only mark as executed - // if the target node also executed, since these are passive connections + // Non-main connections (model/memory/tool) are passive β€” only mark + // success when the target node also produced run data. const isMainConnection = type === NodeConnectionTypes.Main; - const targetNodeHasAnyExecution = nodeExecutionRunDataById.value[connection.target]; - - if (isMainConnection || targetNodeHasAnyExecution) { + if (isMainConnection || targetRunData) { status = 'success'; } } - const sourceInputs = renderData.value.nodeInputsByNodeId.get(connection.source)?.value ?? []; - const targetInputs = renderData.value.nodeInputsByNodeId.get(connection.target)?.value ?? []; + const sourceInputs = rd.nodeInputsByNodeId.get(connection.source)?.value ?? []; + const targetInputs = rd.nodeInputsByNodeId.get(connection.target)?.value ?? []; const maxConnections = [...sourceInputs, ...targetInputs] .filter((port) => port.type === type) .reduce((acc, port) => { - if (port.maxConnections === undefined) { - return acc; - } - + if (port.maxConnections === undefined) return acc; return Math.min(acc ?? Infinity, port.maxConnections); }, undefined); @@ -707,76 +193,61 @@ export function useCanvasMapping({ }; } - function getConnectionType(_: CanvasConnection): string { - return 'canvas-edge'; - } - function getConnectionLabel(connection: CanvasConnection): string { - const fromNode = nodesByName.value.get(connection.data?.source.node ?? ''); - if (!fromNode) { - return ''; - } + const rd = renderData.value; + const sourceId = connection.source; - if (nodePinnedDataById.value[fromNode.id]) { - const pinnedDataCount = nodePinnedDataById.value[fromNode.id]?.length ?? 0; + const pinned = rd.pinnedDataByNodeId.get(sourceId)?.value; + if (pinned) { + const pinnedDataCount = pinned.length; return pinnedDataCount > 0 ? i18n.baseText('ndv.output.items', { adjustToNumber: pinnedDataCount, interpolate: { count: String(pinnedDataCount) }, }) : ''; - } else if (nodeExecutionRunDataById.value[fromNode.id]) { - const { type, index } = parseCanvasConnectionHandleString(connection.sourceHandle); - const outputData = nodeExecutionRunDataOutputMapById.value[fromNode.id]?.[type]?.[index]; - - // For non-main connections, use per-target data if available - const isMainConnection = type === NodeConnectionTypes.Main; - const targetHasExecutionData = nodeExecutionRunDataById.value[connection.target]; - - if (!isMainConnection && outputData?.byTarget) { - // Look up the target node to get per-connection counts - const targetNodeId = connection.target; - const targetData = outputData.byTarget[targetNodeId]; - - if (targetData && targetData.total > 0 && targetHasExecutionData) { - return i18n.baseText( - targetData.iterations > 1 ? 'ndv.output.itemsTotal' : 'ndv.output.items', - { - adjustToNumber: targetData.total, - interpolate: { count: String(targetData.total) }, - }, - ); - } - - // Target hasn't executed, show no label - return ''; - } - - // For main connections, use aggregate counts - const runDataTotal = outputData?.total ?? 0; - const hasMultipleRunDataIterations = (outputData?.iterations ?? 1) > 1; - - return runDataTotal > 0 && (isMainConnection || targetHasExecutionData) - ? i18n.baseText( - hasMultipleRunDataIterations ? 'ndv.output.itemsTotal' : 'ndv.output.items', - { - adjustToNumber: runDataTotal, - interpolate: { count: String(runDataTotal) }, - }, - ) - : ''; } - return ''; + const sourceRunData = rd.executionRunDataByNodeId.get(sourceId)?.value; + if (!sourceRunData) return ''; + + const { type, index } = parseCanvasConnectionHandleString(connection.sourceHandle); + const outputMap = rd.executionRunDataOutputMapByNodeId.get(sourceId); + const outputData = outputMap?.[type]?.[index]; + + const isMainConnection = type === NodeConnectionTypes.Main; + const targetRunData = rd.executionRunDataByNodeId.get(connection.target)?.value; + + // Non-main connections (AI tool/memory/embedding) track per-target counts + // when the target has run data; otherwise stay quiet. + if (!isMainConnection && outputData?.byTarget) { + const targetData = outputData.byTarget[connection.target]; + if (targetData && targetData.total > 0 && targetRunData) { + return i18n.baseText( + targetData.iterations > 1 ? 'ndv.output.itemsTotal' : 'ndv.output.items', + { + adjustToNumber: targetData.total, + interpolate: { count: String(targetData.total) }, + }, + ); + } + return ''; + } + + const runDataTotal = outputData?.total ?? 0; + const hasMultipleRunDataIterations = (outputData?.iterations ?? 1) > 1; + + return runDataTotal > 0 && (isMainConnection || targetRunData) + ? i18n.baseText(hasMultipleRunDataIterations ? 'ndv.output.itemsTotal' : 'ndv.output.items', { + adjustToNumber: runDataTotal, + interpolate: { count: String(runDataTotal) }, + }) + : ''; } return { - additionalNodePropertiesById, - nodeExecutionRunDataOutputMapById, - nodeExecutionWaitingForNextById, - nodeHasIssuesById, - nodeDisplaySizeById, - connections: mappedConnections, nodes: mappedNodes, + connections: mappedConnections, + nodeDisplaySizeById, }; } diff --git a/packages/frontend/editor-ui/src/features/workflows/workflowDiff/SyncedWorkflowCanvas.test.ts b/packages/frontend/editor-ui/src/features/workflows/workflowDiff/SyncedWorkflowCanvas.test.ts index 31bed799e75..bf1c14bfeae 100644 --- a/packages/frontend/editor-ui/src/features/workflows/workflowDiff/SyncedWorkflowCanvas.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/workflowDiff/SyncedWorkflowCanvas.test.ts @@ -4,6 +4,7 @@ import { createTestingPinia } from '@pinia/testing'; import { render } from '@testing-library/vue'; import type { EventBus } from '@n8n/utils/event-bus'; import type { CanvasEventBusEvents } from '@/features/workflows/canvas/canvas.types'; +import { createEmptyCanvasRenderData } from '@/features/workflows/canvas/canvas.utils'; // Mock useVueFlow - capture onNodesInitialized callback and updateNode spy let nodesInitializedCallback: (() => void) | null = null; @@ -92,12 +93,7 @@ describe('SyncedWorkflowCanvas', () => { id: 'test-canvas', nodes: [], connections: [], - renderData: { - nodeInputsByNodeId: new Map(), - nodeOutputsByNodeId: new Map(), - pinnedDataByNodeName: {}, - executionIssuesByNodeName: new Map(), - }, + renderData: createEmptyCanvasRenderData(), }, }); expect(container).toBeTruthy(); @@ -111,12 +107,7 @@ describe('SyncedWorkflowCanvas', () => { nodes: [], connections: [], applyLayout: true, - renderData: { - nodeInputsByNodeId: new Map(), - nodeOutputsByNodeId: new Map(), - pinnedDataByNodeName: {}, - executionIssuesByNodeName: new Map(), - }, + renderData: createEmptyCanvasRenderData(), }, }); @@ -139,12 +130,7 @@ describe('SyncedWorkflowCanvas', () => { nodes: [], connections: [], applyLayout: false, - renderData: { - nodeInputsByNodeId: new Map(), - nodeOutputsByNodeId: new Map(), - pinnedDataByNodeName: {}, - executionIssuesByNodeName: new Map(), - }, + renderData: createEmptyCanvasRenderData(), }, }); @@ -163,12 +149,7 @@ describe('SyncedWorkflowCanvas', () => { id: 'test-canvas', nodes: [], connections: [], - renderData: { - nodeInputsByNodeId: new Map(), - nodeOutputsByNodeId: new Map(), - pinnedDataByNodeName: {}, - executionIssuesByNodeName: new Map(), - }, + renderData: createEmptyCanvasRenderData(), }, }); @@ -188,12 +169,7 @@ describe('SyncedWorkflowCanvas', () => { nodes: [], connections: [], applyLayout: true, - renderData: { - nodeInputsByNodeId: new Map(), - nodeOutputsByNodeId: new Map(), - pinnedDataByNodeName: {}, - executionIssuesByNodeName: new Map(), - }, + renderData: createEmptyCanvasRenderData(), }, }); diff --git a/packages/frontend/editor-ui/src/features/workflows/workflowDiff/WorkflowDiffContent.test.ts b/packages/frontend/editor-ui/src/features/workflows/workflowDiff/WorkflowDiffContent.test.ts index b07c659ed67..075dad31d1f 100644 --- a/packages/frontend/editor-ui/src/features/workflows/workflowDiff/WorkflowDiffContent.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/workflowDiff/WorkflowDiffContent.test.ts @@ -3,6 +3,7 @@ import { defineComponent, h, ref } from 'vue'; import { createTestingPinia } from '@pinia/testing'; import { render, screen } from '@testing-library/vue'; import type { INodeUi } from '@/Interface'; +import { createEmptyCanvasRenderData } from '@/features/workflows/canvas/canvas.utils'; interface CapturedCanvasProps { id: string; @@ -102,18 +103,8 @@ describe('WorkflowDiffContent', () => { isSourceWorkflowNew: false, nodesDiff: new Map(), connectionsDiff: new Map(), - sourceRenderData: { - nodeInputsByNodeId: new Map(), - nodeOutputsByNodeId: new Map(), - pinnedDataByNodeName: {}, - executionIssuesByNodeName: new Map(), - }, - targetRenderData: { - nodeInputsByNodeId: new Map(), - nodeOutputsByNodeId: new Map(), - pinnedDataByNodeName: {}, - executionIssuesByNodeName: new Map(), - }, + sourceRenderData: createEmptyCanvasRenderData(), + targetRenderData: createEmptyCanvasRenderData(), }; describe('panels', () => { diff --git a/packages/frontend/editor-ui/src/features/workflows/workflowDiff/useWorkflowDiff.test.ts b/packages/frontend/editor-ui/src/features/workflows/workflowDiff/useWorkflowDiff.test.ts index 41353469c42..26cd8263f9c 100644 --- a/packages/frontend/editor-ui/src/features/workflows/workflowDiff/useWorkflowDiff.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/workflowDiff/useWorkflowDiff.test.ts @@ -1,11 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ref, computed } from 'vue'; import { mapConnections, useWorkflowDiff } from './useWorkflowDiff'; -import type { - CanvasConnection, - CanvasNode, - ExecutionOutputMap, -} from '@/features/workflows/canvas/canvas.types'; +import type { CanvasConnection, CanvasNode } from '@/features/workflows/canvas/canvas.types'; +import type { ExecutionOutputMap } from '@/app/types/executionData'; import type { INodeUi, IWorkflowDb } from '@/Interface'; import { NodeDiffStatus, type IConnections } from 'n8n-workflow'; import { useCanvasMapping } from '@/features/workflows/canvas/composables/useCanvasMapping'; @@ -18,11 +15,6 @@ vi.mock('@/app/stores/workflows.store', () => ({ })); const mockDocumentStore = vi.hoisted(() => ({ - createWorkflowObject: vi.fn().mockReturnValue({ - id: 'test-workflow', - nodes: [], - connections: {}, - }), hydrate: vi.fn(), render: { nodeInputsByNodeId: new Map(), @@ -36,10 +28,18 @@ const mockDocumentStore = vi.hoisted(() => ({ vi.mock('@/app/stores/workflowDocument.store', () => ({ useWorkflowDocumentStore: () => mockDocumentStore, createWorkflowDocumentId: vi.fn().mockReturnValue('test-id'), - injectWorkflowDocumentStore: () => ({ value: mockDocumentStore }), disposeWorkflowDocumentStore: vi.fn(), })); +vi.mock('@/app/stores/workflowDocument/useWorkflowDocumentRenderData', async () => { + const { createEmptyCanvasRenderData } = await vi.importActual< + typeof import('@/features/workflows/canvas/canvas.utils') + >('@/features/workflows/canvas/canvas.utils'); + return { + useWorkflowDocumentRenderData: vi.fn(() => createEmptyCanvasRenderData()), + }; +}); + vi.mock('@/app/stores/workflowExecutionState.store', () => ({ useWorkflowExecutionStateStore: () => ({ activeExecutionIssuesByNodeName: new Map(), @@ -428,5 +428,28 @@ describe('useWorkflowDiff', () => { // Node with existing ID should keep its ID expect(passedNodes[1].id).toBe('existing-id'); }); + + it('hydrates the render-data store with the same node IDs used for canvas mapping', () => { + const nodeWithoutId = { + name: 'Node Without ID', + type: 'test-node', + typeVersion: 1, + position: [100, 100] as [number, number], + parameters: {}, + } as INodeUi; + + const sourceWorkflow = createMockWorkflow('source', [nodeWithoutId]); + + useWorkflowDiff(sourceWorkflow, undefined); + + // Canvas mapping receives nodes with generated IDs; the render-data store + // must be hydrated with those same IDs, otherwise canvas lookups + // (handles, render type, subtitle, status) miss. + const canvasNodes = mockUseCanvasMapping.mock.calls[0][0].nodes.value; + const hydratedNodes = mockDocumentStore.hydrate.mock.calls[0][0].nodes; + + expect(canvasNodes[0].id).toBeDefined(); + expect(hydratedNodes[0].id).toBe(canvasNodes[0].id); + }); }); }); diff --git a/packages/frontend/editor-ui/src/features/workflows/workflowDiff/useWorkflowDiff.ts b/packages/frontend/editor-ui/src/features/workflows/workflowDiff/useWorkflowDiff.ts index 86fd471469d..3ac846335bb 100644 --- a/packages/frontend/editor-ui/src/features/workflows/workflowDiff/useWorkflowDiff.ts +++ b/packages/frontend/editor-ui/src/features/workflows/workflowDiff/useWorkflowDiff.ts @@ -1,19 +1,32 @@ import type { CanvasConnection, CanvasNode } from '@/features/workflows/canvas/canvas.types'; import type { INodeUi, IWorkflowDb } from '@/Interface'; -import type { MaybeRefOrGetter, Ref, ComputedRef } from 'vue'; -import { toValue, computed, ref, watchEffect, shallowRef, onScopeDispose } from 'vue'; +import { + toValue, + computed, + ref, + watchEffect, + shallowRef, + onScopeDispose, + effectScope, + type MaybeRefOrGetter, + type Ref, + type ComputedRef, + type EffectScope, +} from 'vue'; import { useCanvasMapping } from '@/features/workflows/canvas/composables/useCanvasMapping'; -import type { Workflow, IConnections, INodeTypeDescription, NodeDiff } from 'n8n-workflow'; +import type { IConnections, INodeTypeDescription, NodeDiff } from 'n8n-workflow'; import { compareWorkflowsNodes, NodeDiffStatus } from 'n8n-workflow'; import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; import { - injectWorkflowDocumentStore, useWorkflowDocumentStore, createWorkflowDocumentId, disposeWorkflowDocumentStore, } from '@/app/stores/workflowDocument.store'; import { useWorkflowDocumentRenderData } from '@/app/stores/workflowDocument/useWorkflowDocumentRenderData'; -import type { CanvasRenderData } from '@/features/workflows/canvas/canvas.utils'; +import { + createEmptyCanvasRenderData, + type CanvasRenderData, +} from '@/features/workflows/canvas/canvas.utils'; export function mapConnections(connections: CanvasConnection[]) { return connections.reduce( @@ -26,14 +39,10 @@ export function mapConnections(connections: CanvasConnection[]) { ); } -function createWorkflowRefs( - workflow: MaybeRefOrGetter, - createWorkflowObject: (nodes: INodeUi[], connections: IConnections) => Workflow, -) { +function createWorkflowRefs(workflow: MaybeRefOrGetter) { const workflowRef = computed(() => toValue(workflow)); const workflowNodes = ref([]); const workflowConnections = ref({}); - const workflowObjectRef = shallowRef(createWorkflowObject([], {})); watchEffect(() => { const workflowValue = workflowRef.value; @@ -47,7 +56,6 @@ function createWorkflowRefs( return node; }); - workflowObjectRef.value = createWorkflowObject(nodesWithIds, workflowValue.connections); workflowNodes.value = nodesWithIds; workflowConnections.value = workflowValue.connections; } @@ -57,7 +65,6 @@ function createWorkflowRefs( workflowRef, workflowNodes, workflowConnections, - workflowObjectRef, }; } @@ -65,15 +72,11 @@ function createWorkflowDiff( workflowRef: ComputedRef, workflowNodes: Ref, workflowConnections: Ref, - workflowObjectRef: Ref, renderData: Ref, ) { - // Call useCanvasMapping at setup time, not inside computed - // This is required because useCanvasMapping uses inject() internally const { nodes, connections } = useCanvasMapping({ nodes: workflowNodes, connections: workflowConnections, - workflowObject: workflowObjectRef, renderData, }); @@ -111,14 +114,16 @@ function createWorkflowDiff( }; } -function createDiffRenderData(workflowRef: ComputedRef, side: string) { - const renderData = shallowRef({ - nodeInputsByNodeId: new Map(), - nodeOutputsByNodeId: new Map(), - pinnedDataByNodeName: {}, - executionIssuesByNodeName: new Map(), - }); +function createDiffRenderData( + workflowRef: ComputedRef, + workflowNodes: Ref, + side: string, +) { + const renderData = shallowRef(createEmptyCanvasRenderData()); let workflowDocumentStore: ReturnType | null = null; + // `useWorkflowDocumentRenderData` is side-effectful; own its scope so it can + // be torn down when the diffed workflow changes or this side disposes. + let renderDataScope: EffectScope | undefined; watchEffect(() => { const wf = workflowRef.value; @@ -127,16 +132,30 @@ function createDiffRenderData(workflowRef: ComputedRef, if (workflowDocumentStore) { disposeWorkflowDocumentStore(workflowDocumentStore); } + renderDataScope?.stop(); const versionId = wf.versionId ?? `diff-${side}`; const docId = createWorkflowDocumentId(wf.id, versionId); workflowDocumentStore = useWorkflowDocumentStore(docId); - workflowDocumentStore.hydrate({ ...wf, versionId } as IWorkflowDb); - renderData.value = useWorkflowDocumentRenderData(docId); + // Hydrate from the same normalized nodes that feed the canvas so the + // render-data maps are keyed by the same node IDs the canvas looks up. + // Shallow-copy the nodes so the document store owns/mutates its own node + // objects (e.g. position snapping) without leaking into `workflowNodes`. + workflowDocumentStore.hydrate({ + ...wf, + nodes: workflowNodes.value.map((node) => ({ ...node })), + versionId, + } as IWorkflowDb); + renderDataScope = effectScope(true); + renderDataScope.run(() => { + renderData.value = useWorkflowDocumentRenderData(docId); + }); }); function dispose() { + renderDataScope?.stop(); + renderDataScope = undefined; if (workflowDocumentStore) { disposeWorkflowDocumentStore(workflowDocumentStore); workflowDocumentStore = null; @@ -150,24 +169,19 @@ export const useWorkflowDiff = ( sourceWorkflow: MaybeRefOrGetter, targetWorkflow: MaybeRefOrGetter, ) => { - const workflowDocumentStore = injectWorkflowDocumentStore(); const nodeTypesStore = useNodeTypesStore(); - const sourceRefs = createWorkflowRefs( - sourceWorkflow, - workflowDocumentStore.value.createWorkflowObject, - ); - const targetRefs = createWorkflowRefs( - targetWorkflow, - workflowDocumentStore.value.createWorkflowObject, - ); + const sourceRefs = createWorkflowRefs(sourceWorkflow); + const targetRefs = createWorkflowRefs(targetWorkflow); const { renderData: sourceRenderData, dispose: disposeSource } = createDiffRenderData( sourceRefs.workflowRef, + sourceRefs.workflowNodes, 'source', ); const { renderData: targetRenderData, dispose: disposeTarget } = createDiffRenderData( targetRefs.workflowRef, + targetRefs.workflowNodes, 'target', ); @@ -175,7 +189,6 @@ export const useWorkflowDiff = ( sourceRefs.workflowRef, sourceRefs.workflowNodes, sourceRefs.workflowConnections, - sourceRefs.workflowObjectRef, sourceRenderData, ); @@ -183,7 +196,6 @@ export const useWorkflowDiff = ( targetRefs.workflowRef, targetRefs.workflowNodes, targetRefs.workflowConnections, - targetRefs.workflowObjectRef, targetRenderData, );