diff --git a/packages/frontend/@n8n/composables/src/structuralComputed.test.ts b/packages/frontend/@n8n/composables/src/structuralComputed.test.ts new file mode 100644 index 00000000000..98e0682b1b7 --- /dev/null +++ b/packages/frontend/@n8n/composables/src/structuralComputed.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi } from 'vitest'; +import { effect, ref } from 'vue'; + +import { structuralComputed } from './structuralComputed'; + +describe('structuralComputed', () => { + it('evaluates the derivation lazily — not until .value is read', () => { + const derive = vi.fn(() => 42); + const trigger = ref(0); + + const r = structuralComputed(() => { + // Subscribe to a reactive dep so we can later invalidate the cache. + void trigger.value; + return derive(); + }); + + expect(derive).not.toHaveBeenCalled(); + expect(r.value).toBe(42); + expect(derive).toHaveBeenCalledTimes(1); + }); + + it('returns the cached reference when the new value is deeply equal', () => { + const trigger = ref(0); + + const r = structuralComputed<{ items: number[] }>( + () => { + void trigger.value; + return { items: [1, 2, 3] }; + }, + (a, b) => a.items.length === b.items.length && a.items.every((v, i) => v === b.items[i]), + ); + + const first = r.value; + trigger.value++; + const second = r.value; + + expect(second).toBe(first); + }); + + it('updates the cached reference when the new value differs', () => { + const trigger = ref(0); + + const r = structuralComputed<{ items: number[] }>( + () => { + const t = trigger.value; + return { items: [t] }; + }, + (a, b) => a.items.length === b.items.length && a.items.every((v, i) => v === b.items[i]), + ); + + const first = r.value; + expect(first).toEqual({ items: [0] }); + + trigger.value = 1; + const second = r.value; + + expect(second).not.toBe(first); + expect(second).toEqual({ items: [1] }); + }); + + it('defaults to Object.is for equality when no isEqual is provided', () => { + const trigger = ref(0); + const obj = { stable: true }; + + const r = structuralComputed(() => { + void trigger.value; + return obj; + }); + + const first = r.value; + trigger.value++; + const second = r.value; + + // Same reference both times — `derive` returned the same object. + expect(second).toBe(first); + }); + + it('does not notify subscribers when the value is structurally equal', () => { + const trigger = ref(0); + + const r = structuralComputed<{ items: number[] }>( + () => { + void trigger.value; + return { items: [1, 2, 3] }; + }, + (a, b) => a.items.length === b.items.length && a.items.every((v, i) => v === b.items[i]), + ); + + const observer = vi.fn(); + effect(() => { + observer(r.value); + }); + expect(observer).toHaveBeenCalledTimes(1); + + trigger.value++; + expect(observer).toHaveBeenCalledTimes(1); // unchanged + }); + + it('notifies subscribers when the value changes', () => { + const trigger = ref(0); + + const r = structuralComputed<{ count: number }>( + () => ({ count: trigger.value }), + (a, b) => a.count === b.count, + ); + + const observer = vi.fn(); + effect(() => { + observer(r.value); + }); + expect(observer).toHaveBeenCalledTimes(1); + + trigger.value = 1; + expect(observer).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/frontend/@n8n/composables/src/structuralComputed.ts b/packages/frontend/@n8n/composables/src/structuralComputed.ts new file mode 100644 index 00000000000..aee63a81d7e --- /dev/null +++ b/packages/frontend/@n8n/composables/src/structuralComputed.ts @@ -0,0 +1,34 @@ +import { computed, type ComputedRef } from 'vue'; + +/** + * Wraps a derivation in a `computed` that returns the same reference when the + * new value is considered equal to the previous one by the provided `isEqual` + * function (defaults to `Object.is`). + * + * Vue's `computed` uses `Object.is` on return values to decide whether to + * notify subscribers. By returning a stable reference on equal content, + * consumers only re-render when the value actually changes — without each + * consumer writing its own equality gate. + * + * Typical use: pass a deep-equality function (e.g. `lodash/isEqual`) for + * derivations that produce fresh objects/arrays on every evaluation but are + * structurally identical most of the time. + * + * @example + * import isEqual from 'lodash/isEqual'; + * const ports = structuralComputed(() => computePorts(...), isEqual); + */ +export function structuralComputed( + derive: () => T, + isEqual: (a: T, b: T) => boolean = Object.is, +): ComputedRef { + let cached: T; + let primed = false; + return computed(() => { + const next = derive(); + if (primed && isEqual(cached, next)) return cached; + cached = next; + primed = true; + return cached; + }); +} diff --git a/packages/frontend/editor-ui/src/app/composables/useNodeConnections.test.ts b/packages/frontend/editor-ui/src/app/composables/useNodeConnections.test.ts index bfe83d407c4..037440540fb 100644 --- a/packages/frontend/editor-ui/src/app/composables/useNodeConnections.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/useNodeConnections.test.ts @@ -1,7 +1,10 @@ import { ref } from 'vue'; import { NodeConnectionTypes } from 'n8n-workflow'; import { useNodeConnections } from '@/app/composables/useNodeConnections'; -import type { CanvasNodeData } from '@/features/workflows/canvas/canvas.types'; +import type { + CanvasConnectionPort, + CanvasNodeData, +} from '@/features/workflows/canvas/canvas.types'; import { CanvasConnectionMode } from '@/features/workflows/canvas/canvas.types'; import { createCanvasConnectionHandleString } from '@/features/workflows/canvas/canvas.utils'; @@ -12,13 +15,13 @@ describe('useNodeConnections', () => { }; describe('mainInputs', () => { it('should return main inputs when provided with main inputs', () => { - const inputs = ref([ + const inputs = ref([ { type: NodeConnectionTypes.Main, index: 0 }, { type: NodeConnectionTypes.Main, index: 1 }, { type: NodeConnectionTypes.Main, index: 2 }, { type: NodeConnectionTypes.AiAgent, index: 0 }, ]); - const outputs = ref([]); + const outputs = ref([]); const { mainInputs } = useNodeConnections({ inputs, @@ -33,12 +36,12 @@ describe('useNodeConnections', () => { describe('nonMainInputs', () => { it('should return non-main inputs when provided with non-main inputs', () => { - const inputs = ref([ + const inputs = ref([ { type: NodeConnectionTypes.Main, index: 0 }, { type: NodeConnectionTypes.AiAgent, index: 0 }, { type: NodeConnectionTypes.AiAgent, index: 1 }, ]); - const outputs = ref([]); + const outputs = ref([]); const { nonMainInputs } = useNodeConnections({ inputs, @@ -53,12 +56,12 @@ describe('useNodeConnections', () => { describe('requiredNonMainInputs', () => { it('should return required non-main inputs when provided with required non-main inputs', () => { - const inputs = ref([ + const inputs = ref([ { type: NodeConnectionTypes.Main, index: 0 }, { type: NodeConnectionTypes.AiAgent, required: true, index: 0 }, { type: NodeConnectionTypes.AiAgent, required: false, index: 1 }, ]); - const outputs = ref([]); + const outputs = ref([]); const { requiredNonMainInputs } = useNodeConnections({ inputs, @@ -73,8 +76,8 @@ describe('useNodeConnections', () => { describe('mainInputConnections', () => { it('should return main input connections when provided with main input connections', () => { - const inputs = ref([]); - const outputs = ref([]); + const inputs = ref([]); + const outputs = ref([]); const connections = ref({ [CanvasConnectionMode.Input]: { [NodeConnectionTypes.Main]: [ @@ -100,8 +103,8 @@ describe('useNodeConnections', () => { describe('mainOutputs', () => { it('should return main outputs when provided with main outputs', () => { - const inputs = ref([]); - const outputs = ref([ + const inputs = ref([]); + const outputs = ref([ { type: NodeConnectionTypes.Main, index: 0 }, { type: NodeConnectionTypes.Main, index: 1 }, { type: NodeConnectionTypes.Main, index: 2 }, @@ -121,8 +124,8 @@ describe('useNodeConnections', () => { describe('nonMainOutputs', () => { it('should return non-main outputs when provided with non-main outputs', () => { - const inputs = ref([]); - const outputs = ref([ + const inputs = ref([]); + const outputs = ref([ { type: NodeConnectionTypes.Main, index: 0 }, { type: NodeConnectionTypes.AiAgent, index: 0 }, { type: NodeConnectionTypes.AiAgent, index: 1 }, @@ -141,8 +144,8 @@ describe('useNodeConnections', () => { describe('mainOutputConnections', () => { it('should return main output connections when provided with main output connections', () => { - const inputs = ref([]); - const outputs = ref([]); + const inputs = ref([]); + const outputs = ref([]); const connections = ref({ [CanvasConnectionMode.Input]: {}, [CanvasConnectionMode.Output]: { @@ -167,8 +170,8 @@ describe('useNodeConnections', () => { }); describe('isValidConnection', () => { - const inputs = ref([]); - const outputs = ref([]); + const inputs = ref([]); + const outputs = ref([]); const { isValidConnection } = useNodeConnections({ inputs, diff --git a/packages/frontend/editor-ui/src/app/composables/useNodeConnections.ts b/packages/frontend/editor-ui/src/app/composables/useNodeConnections.ts index 2eadc923a37..2575701e2d9 100644 --- a/packages/frontend/editor-ui/src/app/composables/useNodeConnections.ts +++ b/packages/frontend/editor-ui/src/app/composables/useNodeConnections.ts @@ -1,4 +1,7 @@ -import type { CanvasNodeData } from '@/features/workflows/canvas/canvas.types'; +import type { + CanvasConnectionPort, + CanvasNodeData, +} from '@/features/workflows/canvas/canvas.types'; import { CanvasConnectionMode } from '@/features/workflows/canvas/canvas.types'; import type { MaybeRef } from 'vue'; import { computed, unref } from 'vue'; @@ -11,8 +14,8 @@ export function useNodeConnections({ outputs, connections, }: { - inputs: MaybeRef; - outputs: MaybeRef; + inputs: MaybeRef; + outputs: MaybeRef; connections: MaybeRef; }) { /** diff --git a/packages/frontend/editor-ui/src/app/constants/injectionKeys.ts b/packages/frontend/editor-ui/src/app/constants/injectionKeys.ts index a1e723bd6e9..b1538f61240 100644 --- a/packages/frontend/editor-ui/src/app/constants/injectionKeys.ts +++ b/packages/frontend/editor-ui/src/app/constants/injectionKeys.ts @@ -9,6 +9,7 @@ import type { TelemetryContext } from '@/app/types/telemetry'; import type { WorkflowState } from '@/app/composables/useWorkflowState'; import type { useExecutionDataStore } from '@/app/stores/executionData.store'; import type { WorkflowDocumentStore } from '@/app/stores/workflowDocument.store'; +import type { CanvasRenderData } from '@/features/workflows/canvas/canvas.utils'; import type { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store'; import type { useNDVStore } from '@/features/ndv/shared/ndv.store'; @@ -33,5 +34,6 @@ export const WorkflowExecutionStateStoreKey: InjectionKey< > = Symbol('WorkflowExecutionStateStore'); export const NDVStoreKey: InjectionKey | null>> = Symbol('NDVStore'); +export const CanvasRenderDataKey: InjectionKey> = Symbol('CanvasRenderData'); export const ChatHubToolContextKey: InjectionKey = Symbol('ChatHubToolContext'); export const AiBuilderScrollToBottomKey: InjectionKey<() => void> = Symbol('ChatScrollToBottom'); 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 0f12c5108e8..15d27a3aab3 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflowDocument.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/workflowDocument.store.ts @@ -20,6 +20,7 @@ import { useWorkflowDocumentTimestamps } from './workflowDocument/useWorkflowDoc import { useWorkflowDocumentParentFolder } from './workflowDocument/useWorkflowDocumentParentFolder'; import { useWorkflowDocumentUsedCredentials } from './workflowDocument/useWorkflowDocumentUsedCredentials'; import { useWorkflowDocumentNodes } from './workflowDocument/useWorkflowDocumentNodes'; +import { useWorkflowDocumentRenderData } from './workflowDocument/useWorkflowDocumentRenderData'; import { useWorkflowDocumentVersionData } from './workflowDocument/useWorkflowDocumentVersionData'; import { useWorkflowDocumentViewport } from './workflowDocument/useWorkflowDocumentViewport'; import { useWorkflowDocumentConnections } from './workflowDocument/useWorkflowDocumentConnections'; @@ -164,12 +165,18 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) { const workflowDocumentVersionData = useWorkflowDocumentVersionData(); const workflowDocumentViewport = useWorkflowDocumentViewport(); const workflowDocumentNodeMetadata = useWorkflowDocumentNodeMetadata(); - const { onStateDirty: onNodesStateDirty, ...workflowDocumentNodes } = useWorkflowDocumentNodes({ + const { + onStateDirty: onNodesStateDirty, + nodeInputsByNodeId, + nodeOutputsByNodeId, + ...workflowDocumentNodes + } = useWorkflowDocumentNodes({ getNodeType: (typeName, version) => nodeTypesStore.getNodeType(typeName, version), nodeMetadata: workflowDocumentNodeMetadata, assignNodeId: (node) => nodeHelpers.assignNodeId(node), syncWorkflowObject: (nodes) => workflowDocumentWorkflowObject.syncWorkflowObjectNodes(nodes), unpinNodeData: (name) => workflowDocumentPinData.unpinNodeData(name), + workflowObject: workflowDocumentWorkflowObject.workflowObject, }); const { onStateDirty: onConnectionsStateDirty, ...workflowDocumentConnections } = useWorkflowDocumentConnections({ @@ -188,6 +195,10 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) { outgoingConnectionsByNodeName: workflowDocumentConnections.outgoingConnectionsByNodeName, incomingConnectionsByNodeName: workflowDocumentConnections.incomingConnectionsByNodeName, }); + const workflowDocumentRenderData = useWorkflowDocumentRenderData({ + nodeInputsByNodeId, + nodeOutputsByNodeId, + }); // --- Cross-cut orchestration --- // Each composable is self-contained and unaware of its siblings. This @@ -395,6 +406,7 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) { ...workflowDocumentExpression, ...workflowDocumentNodeMetadata, ...workflowDocumentNodesIssues, + ...workflowDocumentRenderData, removeAllNodes, hydrate, reset, diff --git a/packages/frontend/editor-ui/src/app/stores/workflowDocument/CLAUDE.md b/packages/frontend/editor-ui/src/app/stores/workflowDocument/CLAUDE.md index 1a77bae1de4..9d2ab4f5c41 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflowDocument/CLAUDE.md +++ b/packages/frontend/editor-ui/src/app/stores/workflowDocument/CLAUDE.md @@ -36,6 +36,37 @@ const onMyChange = createEventHook(); - Expose only the `.on` subscriber: `onMyChange: onMyChange.on` - Use `CHANGE_ACTION.ADD | UPDATE | DELETE` for the `action` field +## Reactivity invariant: events carry reactive references + +When an apply method emits a node (or any tracked entity) in its event +payload, the payload must contain the **reactive proxy** read back from the +ref — NOT the raw input received by the apply method. + +Vue creates proxies lazily on read from a reactive container. The raw object +passed into the apply method and the proxy returned by `nodes.value[i]` share +underlying state but are different JS references. Subscribers that store the +raw reference lose reactivity on subsequent property mutations: property reads +on the raw object create no dependencies, and downstream computeds never +invalidate when the underlying node is mutated through its proxy. + +Pattern — read back from the ref after the mutation, emit the proxy: + +```typescript +function applyAddNode(node: INodeUi) { + nodes.value.push(node); + const reactiveNode = nodes.value[nodes.value.length - 1]; // proxy + void onChange.trigger({ + action: CHANGE_ACTION.ADD, + payload: { node: reactiveNode }, + }); +} +``` + +This invariant is locked by the `addNode emits the reactive proxy from +nodes.value` test in `useWorkflowDocumentNodes.test.ts`. Any new apply method +that emits a tracked entity must follow the same pattern and add an +equivalent test. + ## Adding a new composable — checklist 1. Create event hook with typed payload extending `ChangeEvent` diff --git a/packages/frontend/editor-ui/src/app/stores/workflowDocument/types.ts b/packages/frontend/editor-ui/src/app/stores/workflowDocument/types.ts index 410a09a75a2..2e93f72dbe6 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflowDocument/types.ts +++ b/packages/frontend/editor-ui/src/app/stores/workflowDocument/types.ts @@ -2,6 +2,7 @@ export const CHANGE_ACTION = { ADD: 'add', UPDATE: 'update', DELETE: 'delete', + SET: 'set', } as const; export type ChangeAction = (typeof CHANGE_ACTION)[keyof typeof CHANGE_ACTION]; 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 e8e65b0d99c..6fe12aa2a1d 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 @@ -38,13 +38,17 @@ function createNode(overrides: Partial = {}): INodeUi { return createTestNode({ name: 'Test Node', ...overrides }) as INodeUi; } -function createNodesDeps(): WorkflowDocumentNodesDeps { +function createNodesDeps( + workflowObj?: ReturnType, +): WorkflowDocumentNodesDeps { + const obj = workflowObj ?? useWorkflowDocumentWorkflowObject({ workflowId: '' }); return { getNodeType: vi.fn().mockReturnValue(null), assignNodeId: vi.fn().mockReturnValue(''), syncWorkflowObject: vi.fn(), unpinNodeData: vi.fn(), nodeMetadata: useWorkflowDocumentNodeMetadata(), + workflowObject: obj.workflowObject, }; } @@ -55,14 +59,14 @@ describe('useWorkflowDocumentGraph', () => { beforeEach(() => { setActivePinia(createPinia()); - nodes = useWorkflowDocumentNodes(createNodesDeps()); + workflowObj = useWorkflowDocumentWorkflowObject({ + workflowId: '', + }); + nodes = useWorkflowDocumentNodes(createNodesDeps(workflowObj)); connections = useWorkflowDocumentConnections({ getNodeById: (id) => nodes.getNodeById(id), syncWorkflowObject: vi.fn(), }); - workflowObj = useWorkflowDocumentWorkflowObject({ - workflowId: '', - }); }); function seedAndCreateGraph( 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 c15c9c0ac0c..f25aebefd48 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 @@ -16,8 +16,11 @@ * need to be rewritten every time internals change; round-trips do not. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { computed, nextTick, ref } from 'vue'; import { setActivePinia, createPinia } from 'pinia'; -import { createTestNode } from '@/__tests__/mocks'; +import { mock } from 'vitest-mock-extended'; +import { NodeConnectionTypes, type INodeTypeDescription, type Workflow } from 'n8n-workflow'; +import { createTestNode, mockNodeTypeDescription } from '@/__tests__/mocks'; import type { INodeUi } from '@/Interface'; import { useWorkflowDocumentNodes, @@ -26,9 +29,11 @@ import { import { useWorkflowDocumentNodeMetadata } from './useWorkflowDocumentNodeMetadata'; const getNodeType = vi.fn().mockReturnValue(null); +const communityNodeType = vi.fn().mockReturnValue(undefined); vi.mock('@/app/stores/nodeTypes.store', () => ({ useNodeTypesStore: vi.fn(() => ({ getNodeType, + communityNodeType, })), })); @@ -43,6 +48,9 @@ function createDeps(overrides: Partial = {}): Workflo syncWorkflowObject: vi.fn(), unpinNodeData: vi.fn(), nodeMetadata: useWorkflowDocumentNodeMetadata(), + workflowObject: ref( + mock({ getNode: () => null }), + ) as unknown as WorkflowDocumentNodesDeps['workflowObject'], ...overrides, }; } @@ -423,14 +431,18 @@ describe('useWorkflowDocumentNodes', () => { }); describe('events', () => { - it('setNodes does not fire onNodesChange (initialization path)', () => { + it('setNodes fires onNodesChange with set action', () => { const hookSpy = vi.fn(); + const node = createNode(); const workflowDocumentNodes = useWorkflowDocumentNodes(deps); workflowDocumentNodes.onNodesChange(hookSpy); - workflowDocumentNodes.setNodes([createNode()]); + workflowDocumentNodes.setNodes([node]); - expect(hookSpy).not.toHaveBeenCalled(); + expect(hookSpy).toHaveBeenCalledWith({ + action: 'set', + payload: { nodeIds: [node.id] }, + }); }); it('addNode fires onNodesChange with add action', () => { @@ -443,10 +455,30 @@ describe('useWorkflowDocumentNodes', () => { expect(hookSpy).toHaveBeenCalledWith({ action: 'add', - payload: { node }, + payload: { + node: expect.objectContaining({ id: node.id, name: node.name }), + }, }); }); + it('addNode emits the reactive proxy from nodes.value (not the raw input)', () => { + // Contract: ADD subscribers must receive the Vue-cached reactive proxy, + // not the raw input object. Otherwise, property reads on the payload + // node create no reactive dependencies and later mutations are silent. + // See CLAUDE.md "Reactivity invariant: events carry reactive references". + const hookSpy = vi.fn(); + const rawNode = createNode({ id: 'a', name: 'Foo' }); + + const workflowDocumentNodes = useWorkflowDocumentNodes(deps); + workflowDocumentNodes.onNodesChange(hookSpy); + workflowDocumentNodes.addNode(rawNode); + + const payload = hookSpy.mock.calls[0][0].payload as { node: INodeUi }; + // The emitted node must be the same JS reference Vue caches for the array slot + expect(payload.node).toBe(workflowDocumentNodes.nodesById.value.get('a')); + expect(payload.node).toBe(workflowDocumentNodes.allNodes.value.find((n) => n.id === 'a')); + }); + it('removeNode fires onNodesChange with delete action', () => { const hookSpy = vi.fn(); const node = createNode({ name: 'Target' }); @@ -597,6 +629,57 @@ describe('useWorkflowDocumentNodes', () => { }); }); + describe('index reactivity', () => { + it('property updates on a newly added node propagate to consumers reading via getNodeByName', async () => { + // Regression test for the linter-not-updating bug: when a node was + // added via addNode (rather than setNodes), the index stored the raw + // input object instead of Vue's cached reactive proxy. Consumers + // reading via getNodeByName got the raw object — property reads + // created no reactive dependencies, so later mutations through + // nodes.value[i] (the proxy) were silent to downstream computeds. + const workflowDocumentNodes = useWorkflowDocumentNodes(deps); + const node = createNode({ + id: 'a', + name: 'Foo', + parameters: { mode: 'runOnceForAllItems' }, + }); + workflowDocumentNodes.addNode(node); + + // Mirrors the ndvStore.activeNode → codeEditorMode chain + const observed = computed(() => workflowDocumentNodes.getNodeByName('Foo')?.parameters?.mode); + expect(observed.value).toBe('runOnceForAllItems'); + + workflowDocumentNodes.setNodeParameters({ + name: 'Foo', + value: { mode: 'runOnceForEachItem' }, + }); + await nextTick(); + + expect(observed.value).toBe('runOnceForEachItem'); + }); + + it('property updates on a newly added node propagate to consumers reading via getNodeById', async () => { + const workflowDocumentNodes = useWorkflowDocumentNodes(deps); + const node = createNode({ + id: 'a', + name: 'Foo', + parameters: { mode: 'runOnceForAllItems' }, + }); + workflowDocumentNodes.addNode(node); + + const observed = computed(() => workflowDocumentNodes.getNodeById('a')?.parameters?.mode); + expect(observed.value).toBe('runOnceForAllItems'); + + workflowDocumentNodes.setNodeParameters({ + name: 'Foo', + value: { mode: 'runOnceForEachItem' }, + }); + await nextTick(); + + expect(observed.value).toBe('runOnceForEachItem'); + }); + }); + describe('findNodeByPartialId', () => { test.each([ [[], 'D', undefined], @@ -913,4 +996,252 @@ describe('useWorkflowDocumentNodes', () => { expect(result).toBe(0); }); }); + + describe('port subsystem (nodeInputsByNodeId / nodeOutputsByNodeId)', () => { + function createNodeTypeDescription( + overrides: Partial = {}, + ): INodeTypeDescription { + return mockNodeTypeDescription({ + name: 'test.node', + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + ...overrides, + }); + } + + function createWorkflowObjectForNodes(nodes: INodeUi[]) { + const byName = new Map(nodes.map((n) => [n.name, n])); + return mock({ + getNode: (name: string) => byName.get(name) ?? null, + }); + } + + beforeEach(() => { + getNodeType.mockReturnValue(createNodeTypeDescription()); + communityNodeType.mockReturnValue(undefined); + }); + + describe('initial reconciliation', () => { + it('seeds port entries for nodes set during construction lifetime', () => { + const nodeA = createNode({ name: 'A', id: 'a' }); + const nodeB = createNode({ name: 'B', id: 'b' }); + deps = createDeps({ + workflowObject: ref( + createWorkflowObjectForNodes([nodeA, nodeB]), + ) as unknown as WorkflowDocumentNodesDeps['workflowObject'], + }); + const workflowDocumentNodes = useWorkflowDocumentNodes(deps); + workflowDocumentNodes.setNodes([nodeA, nodeB]); + + expect(workflowDocumentNodes.nodeInputsByNodeId.size).toBe(2); + expect(workflowDocumentNodes.nodeOutputsByNodeId.size).toBe(2); + expect(workflowDocumentNodes.nodeInputsByNodeId.has('a')).toBe(true); + expect(workflowDocumentNodes.nodeInputsByNodeId.has('b')).toBe(true); + }); + + it('starts with empty port maps when no nodes are set', () => { + const workflowDocumentNodes = useWorkflowDocumentNodes(deps); + + expect(workflowDocumentNodes.nodeInputsByNodeId.size).toBe(0); + expect(workflowDocumentNodes.nodeOutputsByNodeId.size).toBe(0); + }); + }); + + describe('addNode', () => { + it('adds a port entry for the new node', () => { + const nodeA = createNode({ name: 'A', id: 'a' }); + deps = createDeps({ + workflowObject: ref( + createWorkflowObjectForNodes([nodeA]), + ) as unknown as WorkflowDocumentNodesDeps['workflowObject'], + }); + const workflowDocumentNodes = useWorkflowDocumentNodes(deps); + workflowDocumentNodes.addNode(nodeA); + + expect(workflowDocumentNodes.nodeInputsByNodeId.has('a')).toBe(true); + expect(workflowDocumentNodes.nodeOutputsByNodeId.has('a')).toBe(true); + }); + + it('does not duplicate the entry for an existing node', () => { + const nodeA = createNode({ name: 'A', id: 'a' }); + deps = createDeps({ + workflowObject: ref( + createWorkflowObjectForNodes([nodeA]), + ) as unknown as WorkflowDocumentNodesDeps['workflowObject'], + }); + const workflowDocumentNodes = useWorkflowDocumentNodes(deps); + workflowDocumentNodes.addNode(nodeA); + const originalInputs = workflowDocumentNodes.nodeInputsByNodeId.get('a'); + workflowDocumentNodes.addNode(nodeA); + + expect(workflowDocumentNodes.nodeInputsByNodeId.size).toBe(1); + expect(workflowDocumentNodes.nodeInputsByNodeId.get('a')).toBe(originalInputs); + }); + }); + + describe('removeNodeById', () => { + it('removes the port entry for the specified node', () => { + const nodeA = createNode({ name: 'A', id: 'a' }); + const nodeB = createNode({ name: 'B', id: 'b' }); + deps = createDeps({ + workflowObject: ref( + createWorkflowObjectForNodes([nodeA, nodeB]), + ) as unknown as WorkflowDocumentNodesDeps['workflowObject'], + }); + const workflowDocumentNodes = useWorkflowDocumentNodes(deps); + workflowDocumentNodes.setNodes([nodeA, nodeB]); + + workflowDocumentNodes.removeNodeById('a'); + + expect(workflowDocumentNodes.nodeInputsByNodeId.has('a')).toBe(false); + expect(workflowDocumentNodes.nodeOutputsByNodeId.has('a')).toBe(false); + expect(workflowDocumentNodes.nodeInputsByNodeId.has('b')).toBe(true); + expect(workflowDocumentNodes.nodeOutputsByNodeId.has('b')).toBe(true); + }); + }); + + describe('removeAllNodes', () => { + it('clears all port entries', () => { + const nodeA = createNode({ name: 'A', id: 'a' }); + const nodeB = createNode({ name: 'B', id: 'b' }); + deps = createDeps({ + workflowObject: ref( + createWorkflowObjectForNodes([nodeA, nodeB]), + ) as unknown as WorkflowDocumentNodesDeps['workflowObject'], + }); + const workflowDocumentNodes = useWorkflowDocumentNodes(deps); + workflowDocumentNodes.setNodes([nodeA, nodeB]); + + workflowDocumentNodes.removeAllNodes(); + + expect(workflowDocumentNodes.nodeInputsByNodeId.size).toBe(0); + expect(workflowDocumentNodes.nodeOutputsByNodeId.size).toBe(0); + }); + }); + + describe('setNodes', () => { + it('reconciles port entries: adds new ids, removes missing ones', () => { + const nodeA = createNode({ name: 'A', id: 'a' }); + const nodeB = createNode({ name: 'B', id: 'b' }); + const nodeC = createNode({ name: 'C', id: 'c' }); + deps = createDeps({ + workflowObject: ref( + createWorkflowObjectForNodes([nodeA, nodeB, nodeC]), + ) as unknown as WorkflowDocumentNodesDeps['workflowObject'], + }); + const workflowDocumentNodes = useWorkflowDocumentNodes(deps); + workflowDocumentNodes.setNodes([nodeA, nodeB]); + expect(workflowDocumentNodes.nodeInputsByNodeId.has('a')).toBe(true); + + workflowDocumentNodes.setNodes([nodeB, nodeC]); + + expect(workflowDocumentNodes.nodeInputsByNodeId.has('a')).toBe(false); + expect(workflowDocumentNodes.nodeInputsByNodeId.has('b')).toBe(true); + expect(workflowDocumentNodes.nodeInputsByNodeId.has('c')).toBe(true); + }); + + it('handles setNodes with empty array (clears entries)', () => { + const nodeA = createNode({ name: 'A', id: 'a' }); + deps = createDeps({ + workflowObject: ref( + createWorkflowObjectForNodes([nodeA]), + ) as unknown as WorkflowDocumentNodesDeps['workflowObject'], + }); + const workflowDocumentNodes = useWorkflowDocumentNodes(deps); + workflowDocumentNodes.setNodes([nodeA]); + expect(workflowDocumentNodes.nodeInputsByNodeId.has('a')).toBe(true); + + workflowDocumentNodes.setNodes([]); + + expect(workflowDocumentNodes.nodeInputsByNodeId.size).toBe(0); + expect(workflowDocumentNodes.nodeOutputsByNodeId.size).toBe(0); + }); + }); + + describe('port computation', () => { + it('returns empty array when node type cannot be resolved', () => { + const nodeA = createNode({ name: 'A', id: 'a' }); + getNodeType.mockReturnValue(null); + communityNodeType.mockReturnValue(undefined); + deps = createDeps({ + workflowObject: ref( + createWorkflowObjectForNodes([nodeA]), + ) as unknown as WorkflowDocumentNodesDeps['workflowObject'], + }); + const workflowDocumentNodes = useWorkflowDocumentNodes(deps); + workflowDocumentNodes.setNodes([nodeA]); + + expect(workflowDocumentNodes.nodeInputsByNodeId.get('a')?.value).toEqual([]); + expect(workflowDocumentNodes.nodeOutputsByNodeId.get('a')?.value).toEqual([]); + }); + + it('falls back to communityNodeType.nodeDescription when getNodeType returns null', () => { + const nodeA = createNode({ name: 'A', id: 'a', type: 'community.foo' }); + const communityDescription = createNodeTypeDescription({ name: 'community.foo' }); + getNodeType.mockReturnValue(null); + communityNodeType.mockReturnValue({ nodeDescription: communityDescription }); + deps = createDeps({ + workflowObject: ref( + createWorkflowObjectForNodes([nodeA]), + ) as unknown as WorkflowDocumentNodesDeps['workflowObject'], + }); + const workflowDocumentNodes = useWorkflowDocumentNodes(deps); + workflowDocumentNodes.setNodes([nodeA]); + + const inputs = workflowDocumentNodes.nodeInputsByNodeId.get('a')?.value; + expect(inputs).toHaveLength(1); + expect(inputs?.[0].type).toBe(NodeConnectionTypes.Main); + }); + + it('maps node type inputs/outputs through canvas port mapper', () => { + const nodeA = createNode({ name: 'A', id: 'a' }); + getNodeType.mockReturnValue( + createNodeTypeDescription({ + inputs: [NodeConnectionTypes.Main, NodeConnectionTypes.AiTool], + outputs: [NodeConnectionTypes.Main], + }), + ); + deps = createDeps({ + workflowObject: ref( + createWorkflowObjectForNodes([nodeA]), + ) as unknown as WorkflowDocumentNodesDeps['workflowObject'], + }); + const workflowDocumentNodes = useWorkflowDocumentNodes(deps); + workflowDocumentNodes.setNodes([nodeA]); + + const inputs = workflowDocumentNodes.nodeInputsByNodeId.get('a')?.value; + const outputs = workflowDocumentNodes.nodeOutputsByNodeId.get('a')?.value; + + expect(inputs).toHaveLength(2); + expect(inputs?.map((p) => p.type)).toEqual([ + NodeConnectionTypes.Main, + NodeConnectionTypes.AiTool, + ]); + expect(outputs).toHaveLength(1); + expect(outputs?.[0].type).toBe(NodeConnectionTypes.Main); + }); + }); + + describe('removal lifecycle', () => { + it('re-adding a removed node creates a fresh computed entry', () => { + const nodeA = createNode({ name: 'A', id: 'a' }); + deps = createDeps({ + workflowObject: ref( + createWorkflowObjectForNodes([nodeA]), + ) as unknown as WorkflowDocumentNodesDeps['workflowObject'], + }); + const workflowDocumentNodes = useWorkflowDocumentNodes(deps); + workflowDocumentNodes.addNode(nodeA); + const originalInputs = workflowDocumentNodes.nodeInputsByNodeId.get('a'); + + workflowDocumentNodes.removeNodeById('a'); + expect(workflowDocumentNodes.nodeInputsByNodeId.has('a')).toBe(false); + + workflowDocumentNodes.addNode(nodeA); + + expect(workflowDocumentNodes.nodeInputsByNodeId.has('a')).toBe(true); + expect(workflowDocumentNodes.nodeInputsByNodeId.get('a')).not.toBe(originalInputs); + }); + }); + }); }); 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 90e373b9bab..37928cff97f 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentNodes.ts +++ b/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentNodes.ts @@ -1,5 +1,14 @@ -import { computed, ref } from 'vue'; +import { + computed, + effectScope, + ref, + shallowReactive, + shallowRef, + type ComputedRef, + type Ref, +} from 'vue'; import { createEventHook } from '@vueuse/core'; +import { structuralComputed } from '@n8n/composables/structuralComputed'; import type { INode, INodeCredentials, @@ -8,6 +17,7 @@ import type { INodeIssueObjectProperty, INodeParameters, INodeTypeDescription, + Workflow, } from 'n8n-workflow'; import { NodeHelpers } from 'n8n-workflow'; import type { @@ -16,6 +26,8 @@ import type { IUpdateInformation, XYPosition, } from '@/Interface'; +import type { CanvasConnectionPort } from '@/features/workflows/canvas/canvas.types'; +import { mapLegacyEndpointsToCanvasConnectionPort } from '@/features/workflows/canvas/canvas.utils'; import { isObject } from '@/app/utils/objectUtils'; import { getCredentialOnlyNodeTypeName } from '@/app/utils/credentialOnlyNodes'; import { snapPositionToGrid } from '@/app/utils/nodeViewUtils'; @@ -33,12 +45,14 @@ import { useNodeTypesStore } from '../nodeTypes.store'; export type NodeAddedPayload = { node: INodeUi }; export type NodeRemovedPayload = { name: string; id: string }; export type NodeUpdatedPayload = { name: string }; +export type NodesSetPayload = { nodeIds: string[] }; export type NodesResetPayload = object; export type NodesChangeEvent = | ChangeEvent | ChangeEvent | ChangeEvent + | ChangeEvent | ChangeEvent; // --- Deps --- @@ -49,6 +63,7 @@ export interface WorkflowDocumentNodesDeps { syncWorkflowObject: (nodes: INodeUi[]) => void; unpinNodeData: (name: string) => void; nodeMetadata: ReturnType; + workflowObject: Ref; } // --- Composable --- @@ -120,6 +135,10 @@ export function useWorkflowDocumentNodes(deps: WorkflowDocumentNodesDeps) { for (const node of newNodes) { deps.nodeMetadata.initPristineNodeMetadata(node.name); } + void onNodesChange.trigger({ + action: CHANGE_ACTION.SET, + payload: { nodeIds: newNodes.map((n) => n.id) }, + }); } function applyAddNode(node: INodeUi) { @@ -128,11 +147,16 @@ export function useWorkflowDocumentNodes(deps: WorkflowDocumentNodesDeps) { } nodes.value.push(node); + // Read back from the reactive array to get Vue's cached proxy. + // Emitting the proxy (not the raw input) ensures ADD subscribers + // receive a reference whose property reads are tracked and whose + // mutations propagate reactivity. See CLAUDE.md "Reactivity invariant". + const reactiveNode = nodes.value[nodes.value.length - 1]; deps.syncWorkflowObject(nodes.value); - deps.nodeMetadata.initNodeMetadata(node.name); + deps.nodeMetadata.initNodeMetadata(reactiveNode.name); void onNodesChange.trigger({ action: CHANGE_ACTION.ADD, - payload: { node }, + payload: { node: reactiveNode }, }); void onStateDirty.trigger(); } @@ -177,13 +201,173 @@ export function useWorkflowDocumentNodes(deps: WorkflowDocumentNodesDeps) { const allNodes = computed(() => nodes.value); - const nodesByName = computed(() => { - return nodes.value.reduce>((acc, node) => { + // Node lookup indices — maintained via onNodesChange events. + // Only rebuilt on add/remove/set, NOT on property updates. Node objects + // are mutated in place, so consumers reading from these indices still + // see the latest property values without needing a rebuild. + const nodesByName = shallowRef>({}); + const nodesById = shallowRef(new Map()); + + // Per-node canvas render data — input/output port maps keyed by node id. + // Each node gets its own structuralComputed for inputs and outputs. + // Lifecycle is managed via onNodesChange events — O(1) per add/remove, + // zero cost on node updates. The structuralComputed handles reactive + // re-evaluation (e.g. when workflowObject or node type changes) and + // isEqual gates downstream propagation. Exposed to canvas consumers via + // useWorkflowDocumentRenderData (a thin grouping façade keyed under + // `store.render`). + const nodeInputsByNodeId = shallowReactive( + new Map>(), + ); + const nodeOutputsByNodeId = shallowReactive( + new Map>(), + ); + const nodePortScopes = new Map void>(); + + function resolveNodePortContext(nodeId: string) { + const node = nodesById.value.get(nodeId); + if (!node) return null; + + const nodeTypeDescription = + nodeTypesStore.getNodeType(node.type, node.typeVersion) ?? + nodeTypesStore.communityNodeType(node.type)?.nodeDescription ?? + null; + + const workflowObjectNode = deps.workflowObject.value.getNode(node.name); + if (!workflowObjectNode || !nodeTypeDescription) return null; + + return { node, nodeTypeDescription, workflowObjectNode }; + } + + function computeNodeInputs(nodeId: string): CanvasConnectionPort[] { + const ctx = resolveNodePortContext(nodeId); + if (!ctx) return []; + + return mapLegacyEndpointsToCanvasConnectionPort( + NodeHelpers.getNodeInputs( + deps.workflowObject.value, + ctx.workflowObjectNode, + ctx.nodeTypeDescription, + ), + ctx.nodeTypeDescription.inputNames ?? [], + ); + } + + function computeNodeOutputs(nodeId: string): CanvasConnectionPort[] { + const ctx = resolveNodePortContext(nodeId); + if (!ctx) return []; + + return mapLegacyEndpointsToCanvasConnectionPort( + NodeHelpers.getNodeOutputs( + deps.workflowObject.value, + ctx.workflowObjectNode, + ctx.nodeTypeDescription, + ), + ctx.nodeTypeDescription.outputNames ?? [], + ); + } + + function applyAddPortEntry(nodeId: string) { + if (nodePortScopes.has(nodeId)) return; + const scope = effectScope(); + scope.run(() => { + nodeInputsByNodeId.set( + nodeId, + structuralComputed(() => computeNodeInputs(nodeId), isEqual), + ); + nodeOutputsByNodeId.set( + nodeId, + structuralComputed(() => computeNodeOutputs(nodeId), isEqual), + ); + }); + nodePortScopes.set(nodeId, () => scope.stop()); + } + + function applyRemovePortEntry(nodeId: string) { + nodePortScopes.get(nodeId)?.(); + nodePortScopes.delete(nodeId); + nodeInputsByNodeId.delete(nodeId); + nodeOutputsByNodeId.delete(nodeId); + } + + function applyReconcilePortEntries(nodeIds: string[]) { + const nextIds = new Set(nodeIds); + for (const oldId of nodePortScopes.keys()) { + if (!nextIds.has(oldId)) applyRemovePortEntry(oldId); + } + for (const id of nodeIds) applyAddPortEntry(id); + } + + function rebuildNodeIndices() { + nodesById.value = new Map(nodes.value.map((n) => [n.id, n])); + nodesByName.value = nodes.value.reduce>((acc, node) => { acc[node.name] = node; return acc; }, {}); + } + + onNodesChange.on((event) => { + switch (event.action) { + case CHANGE_ACTION.ADD: { + const { node } = event.payload as NodeAddedPayload; + nodesById.value = new Map(nodesById.value).set(node.id, node); + nodesByName.value = { ...nodesByName.value, [node.name]: node }; + break; + } + case CHANGE_ACTION.DELETE: { + const { id, name } = event.payload as NodeRemovedPayload; + if (id) { + const nextById = new Map(nodesById.value); + nextById.delete(id); + nodesById.value = nextById; + + const { [name]: _, ...restByName } = nodesByName.value; + nodesByName.value = restByName; + } else { + nodesById.value = new Map(); + nodesByName.value = {}; + } + break; + } + case CHANGE_ACTION.SET: { + rebuildNodeIndices(); + break; + } + } }); + rebuildNodeIndices(); + + // Port lifecycle — registered after the index subscription so the index + // is fresh by the time port handlers run. + onNodesChange.on((event) => { + switch (event.action) { + case CHANGE_ACTION.ADD: { + const { node } = event.payload as NodeAddedPayload; + applyAddPortEntry(node.id); + break; + } + case CHANGE_ACTION.DELETE: { + const { id } = event.payload as NodeRemovedPayload; + if (id) { + applyRemovePortEntry(id); + } else { + // removeAllNodes fires DELETE with empty payload + applyReconcilePortEntries([]); + } + break; + } + case CHANGE_ACTION.SET: { + const { nodeIds } = event.payload as NodesSetPayload; + applyReconcilePortEntries(nodeIds); + break; + } + } + }); + + // Initial reconciliation for nodes that exist before event subscription + applyReconcilePortEntries(nodes.value.map((n) => n.id)); + const canvasNames = computed(() => new Set(allNodes.value.map((n) => n.name))); const workflowTriggerNodes = computed(() => @@ -202,7 +386,7 @@ export function useWorkflowDocumentNodes(deps: WorkflowDocumentNodesDeps) { ); function getNodeById(id: string): INodeUi | undefined { - return nodes.value.find((node) => node.id === id); + return nodesById.value.get(id); } function getNodeByName(name: string): INodeUi | null { @@ -475,6 +659,9 @@ export function useWorkflowDocumentNodes(deps: WorkflowDocumentNodesDeps) { // Read allNodes, nodesByName, + nodesById, + nodeInputsByNodeId, + nodeOutputsByNodeId, canvasNames, workflowTriggerNodes, aiNodes, 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 new file mode 100644 index 00000000000..848cc20270f --- /dev/null +++ b/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentRenderData.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { shallowReactive, type ComputedRef } from 'vue'; +import type { CanvasConnectionPort } from '@/features/workflows/canvas/canvas.types'; +import { useWorkflowDocumentRenderData } from './useWorkflowDocumentRenderData'; + +describe('useWorkflowDocumentRenderData', () => { + it('exposes the injected port maps under render', () => { + const nodeInputsByNodeId = shallowReactive( + new Map>(), + ); + const nodeOutputsByNodeId = shallowReactive( + new Map>(), + ); + + const { render } = useWorkflowDocumentRenderData({ + nodeInputsByNodeId, + nodeOutputsByNodeId, + }); + + // The façade exposes the same map references — no copies, no wrapping. + // Mutations to the underlying maps (done by useWorkflowDocumentNodes) + // are observable through `render` because they share identity. + expect(render.nodeInputsByNodeId).toBe(nodeInputsByNodeId); + expect(render.nodeOutputsByNodeId).toBe(nodeOutputsByNodeId); + }); +}); diff --git a/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentRenderData.ts b/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentRenderData.ts new file mode 100644 index 00000000000..29d4f1e81c1 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentRenderData.ts @@ -0,0 +1,36 @@ +import type { ComputedRef, ShallowReactive } from 'vue'; +import type { CanvasConnectionPort } from '@/features/workflows/canvas/canvas.types'; + +// --- Deps --- + +export interface WorkflowDocumentRenderDataDeps { + /** + * Per-node port maps. State and lifecycle are owned by + * useWorkflowDocumentNodes; this composable is a grouping façade that + * exposes them under a single `render` key for canvas consumers. + */ + nodeInputsByNodeId: ShallowReactive>>; + nodeOutputsByNodeId: ShallowReactive>>; +} + +// --- Composable --- + +/** + * Canvas render data grouping façade. + * + * Takes the per-node input/output port maps owned by + * `useWorkflowDocumentNodes` and exposes them under a single `render` + * key. Spread into the workflow document store so canvas consumers + * access port data via `store.render`. + * + * No state, no lifecycle, no computation — those all live with the maps + * in `useWorkflowDocumentNodes`. + */ +export function useWorkflowDocumentRenderData(deps: WorkflowDocumentRenderDataDeps) { + return { + render: { + nodeInputsByNodeId: deps.nodeInputsByNodeId, + nodeOutputsByNodeId: deps.nodeOutputsByNodeId, + }, + }; +} diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/ExecuteMessage.test.ts b/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/ExecuteMessage.test.ts index d3405b62a05..25d56531443 100644 --- a/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/ExecuteMessage.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/assistant/components/Agent/ExecuteMessage.test.ts @@ -132,7 +132,6 @@ describe('ExecuteMessage', () => { const workflowDocumentStore = useWorkflowDocumentStore( createWorkflowDocumentId('test-workflow'), ); - workflowDocumentStore.setNodes(workflowNodes); workflowDocumentStore.setConnections({}); Object.defineProperty(workflowsStore, 'workflowExecutionData', { get: () => workflowExecutionDataRef, @@ -161,7 +160,14 @@ describe('ExecuteMessage', () => { }); builderStore.trackWorkflowBuilderJourney = vi.fn(); - renderExecuteMessage = () => renderComponent({ pinia }); + renderExecuteMessage = () => { + // Sync the document store with the latest workflowNodes state so + // tests that push nodes to workflowNodes after beforeEach have those + // reflected in the store's event-driven nodesByName / nodesById + // indices. + workflowDocumentStore.setNodes(workflowNodes); + return renderComponent({ pinia }); + }; }); it('disables execution when validation issues exist', () => { 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 05045359919..f71e5237c2e 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 @@ -27,8 +27,6 @@ export function createCanvasNodeData({ type = 'test', typeVersion = 1, disabled = false, - inputs = [], - outputs = [], connections = { [CanvasConnectionMode.Input]: {}, [CanvasConnectionMode.Output]: {} }, execution = { running: false }, issues = { execution: [], validation: [], visible: false }, @@ -50,8 +48,6 @@ export function createCanvasNodeData({ pinnedData, runData, disabled, - inputs, - outputs, connections, render, }; @@ -223,28 +219,32 @@ export function createCanvasHandleProvide({ export function createCanvasConnection( nodeA: CanvasNode, nodeB: CanvasNode, - { sourceIndex = 0, targetIndex = 0 } = {}, + { + sourceIndex = 0, + targetIndex = 0, + sourceType = NodeConnectionTypes.Main as NodeConnectionType, + targetType = NodeConnectionTypes.Main as NodeConnectionType, + } = {}, ) { - const nodeAOutput = nodeA.data?.outputs[sourceIndex]; - const nodeBInput = nodeA.data?.inputs[targetIndex]; - return { id: `${nodeA.id}-${nodeB.id}`, source: nodeA.id, target: nodeB.id, - ...(nodeAOutput ? { sourceHandle: `outputs/${nodeAOutput.type}/${nodeAOutput.index}` } : {}), - ...(nodeBInput ? { targetHandle: `inputs/${nodeBInput.type}/${nodeBInput.index}` } : {}), + sourceHandle: `outputs/${sourceType}/${sourceIndex}`, + targetHandle: `inputs/${targetType}/${targetIndex}`, }; } export function createCanvasGraphEdge( nodeA: GraphNode, nodeB: GraphNode, - { sourceIndex = 0, targetIndex = 0 } = {}, + { + sourceIndex = 0, + targetIndex = 0, + sourceType = NodeConnectionTypes.Main as NodeConnectionType, + targetType = NodeConnectionTypes.Main as NodeConnectionType, + } = {}, ): GraphEdge { - const nodeAOutput = nodeA.data?.outputs[sourceIndex]; - const nodeBInput = nodeA.data?.inputs[targetIndex]; - return { id: `${nodeA.id}-${nodeB.id}`, source: nodeA.id, @@ -259,7 +259,7 @@ export function createCanvasGraphEdge( targetNode: nodeB, data: {}, events: {}, - ...(nodeAOutput ? { sourceHandle: `outputs/${nodeAOutput.type}/${nodeAOutput.index}` } : {}), - ...(nodeBInput ? { targetHandle: `inputs/${nodeBInput.type}/${nodeBInput.index}` } : {}), + sourceHandle: `outputs/${sourceType}/${sourceIndex}`, + targetHandle: `inputs/${targetType}/${targetIndex}`, }; } 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 2817680f156..db5fa25c977 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 @@ -71,12 +71,6 @@ export type CanvasNodeDefaultRender = { configurable: boolean; configuration: boolean; trigger: boolean; - inputs: { - labelSize: CanvasNodeDefaultRenderLabelSize; - }; - outputs: { - labelSize: CanvasNodeDefaultRenderLabelSize; - }; tooltip?: string; dirtiness?: CanvasNodeDirtinessType; icon?: NodeIconSource; @@ -111,8 +105,6 @@ export interface CanvasNodeData { type: INodeUi['type']; typeVersion: INodeUi['typeVersion']; disabled: INodeUi['disabled']; - inputs: CanvasConnectionPort[]; - outputs: CanvasConnectionPort[]; connections: { [CanvasConnectionMode.Input]: INodeConnections; [CanvasConnectionMode.Output]: INodeConnections; 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 ed3a66e392e..fb773d07058 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,13 +4,37 @@ import type { INodeTypeDescription, NodeConnectionType, } from 'n8n-workflow'; +import type { Ref } from 'vue'; import type { INodeUi } from '@/Interface'; -import type { BoundingBox, CanvasConnection, CanvasConnectionPort } from './canvas.types'; +import type { + BoundingBox, + CanvasConnection, + CanvasConnectionPort, + CanvasNodeDefaultRenderLabelSize, +} from './canvas.types'; import { CanvasConnectionMode } from './canvas.types'; import type { Connection } from '@vue-flow/core'; import { isValidCanvasConnectionMode, isValidNodeConnectionType } from '@/app/utils/typeGuards'; import { NodeConnectionTypes } from 'n8n-workflow'; import { NODE_MIN_INPUT_ITEMS_COUNT } from '@/app/constants'; +import { CanvasRenderDataKey } from '@/app/constants/injectionKeys'; +import { injectStrict } from '@/app/utils/injectStrict'; +import type { useWorkflowDocumentRenderData } from '@/app/stores/workflowDocument/useWorkflowDocumentRenderData'; + +/** + * Per-node canvas render data (input/output port maps) shape, as produced by + * the workflow document store's render composable and consumed by canvas + * components. + */ +export type CanvasRenderData = ReturnType['render']; + +/** + * Injects the canvas render data from the component tree. Provided by an + * ancestor canvas component. Throws if no provider is registered. + */ +export function injectCanvasRenderData(): Ref { + return injectStrict(CanvasRenderDataKey); +} /** * Maps multiple legacy n8n connections to VueFlow connections @@ -265,6 +289,34 @@ export function insertSpacersBetweenEndpoints(endpoints: T[], requiredEndpoin return endpointsWithSpacers; } +export function getLabelSize(label: string = ''): number { + if (label.length <= 2) { + return 0; + } else if (label.length <= 6) { + return 1; + } else { + return 2; + } +} + +export function getMaxNodePortsLabelSize( + ports: CanvasConnectionPort[], +): CanvasNodeDefaultRenderLabelSize { + const labelSizes: CanvasNodeDefaultRenderLabelSize[] = ['small', 'medium', 'large']; + const labelSizeIndexes = ports.reduce( + (sizeAcc, input) => { + if (input.type === NodeConnectionTypes.Main) { + sizeAcc.push(getLabelSize(input.label ?? '')); + } + + return sizeAcc; + }, + [0], + ); + + return labelSizes[Math.max(...labelSizeIndexes)]; +} + export function shouldIgnoreCanvasShortcut(el: Element): boolean { return ( ['INPUT', 'TEXTAREA'].includes(el.tagName) || diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/Canvas.test.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/components/Canvas.test.ts index 03f0c28fdf6..4bbbb85f118 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/Canvas.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/Canvas.test.ts @@ -8,7 +8,7 @@ import { createCanvasConnection, createCanvasNodeElement, } from '@/features/workflows/canvas/__tests__/utils'; -import { NodeConnectionTypes } from 'n8n-workflow'; + import type { useDeviceSupport } from '@n8n/composables/useDeviceSupport'; import { useVueFlow } from '@vue-flow/core'; import { SIMULATE_NODE_TYPE } from '@/app/constants'; @@ -24,6 +24,16 @@ vi.mock('@n8n/design-system', async (importOriginal) => { return { ...actual, useDeviceSupport: vi.fn(() => ({ isCtrlKeyPressed: vi.fn() })) }; }); +vi.mock('@/features/workflows/canvas/canvas.utils', async (importOriginal) => ({ + ...(await importOriginal()), + injectCanvasRenderData: vi.fn(() => ({ + value: { + nodeInputsByNodeId: new Map(), + nodeOutputsByNodeId: new Map(), + }, + })), +})); + const canvasId = 'canvas'; let renderComponent: ReturnType; @@ -61,27 +71,11 @@ describe('Canvas', () => { createCanvasNodeElement({ id: '1', label: 'Node 1', - data: { - outputs: [ - { - type: NodeConnectionTypes.Main, - index: 0, - }, - ], - }, }), createCanvasNodeElement({ id: '2', label: 'Node 2', position: { x: 200, y: 200 }, - data: { - inputs: [ - { - type: NodeConnectionTypes.Main, - index: 0, - }, - ], - }, }), ]; diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/Canvas.vue b/packages/frontend/editor-ui/src/features/workflows/canvas/components/Canvas.vue index fd44619a5c6..51dbbebb40c 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/Canvas.vue +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/Canvas.vue @@ -46,6 +46,8 @@ import { getRectOfNodes, MarkerType, PanelPosition, useVueFlow, VueFlow } from ' import { MiniMap } from '@vue-flow/minimap'; import { onKeyDown, onKeyUp, useThrottleFn } from '@vueuse/core'; import { NodeConnectionTypes, type IConnections } from 'n8n-workflow'; +import type { CanvasRenderData } from '../canvas.utils'; +import { CanvasRenderDataKey } from '@/app/constants/injectionKeys'; import { computed, nextTick, @@ -141,6 +143,7 @@ const props = withDefaults( connections: CanvasConnection[]; controlsPosition?: PanelPosition; eventBus?: EventBus; + renderData: CanvasRenderData; readOnly?: boolean; canExecute?: boolean; executing?: boolean; @@ -169,6 +172,9 @@ const props = withDefaults( const { isMobileDevice, controlKeyCode } = useDeviceSupport(); const usersStore = useUsersStore(); const workflowDocumentStore = injectWorkflowDocumentStore(); + +const renderData = toRef(props, 'renderData'); +provide(CanvasRenderDataKey, renderData); const experimentalNdvStore = useExperimentalNdvStore(); const focusedNodesStore = useFocusedNodesStore(); const chatPanelStore = useChatPanelStore(); @@ -213,7 +219,7 @@ const { getDownstreamNodes, getUpstreamNodes, } = useCanvasTraversal(vueFlow); -const { layout } = useCanvasLayout(props.id, isExperimentalNdvActive); +const { layout } = useCanvasLayout(props.id, isExperimentalNdvActive, toRef(props, 'renderData')); const isPaneReady = ref(false); 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 097050fa8ab..5903eca043d 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 @@ -40,6 +40,7 @@ const props = withDefaults( const canvasRef = useTemplateRef('canvas'); const $style = useCssModule(); const workflowDocumentStore = injectWorkflowDocumentStore(); +const renderData = computed(() => workflowDocumentStore.value.render); const { onNodesInitialized, viewport, viewportRef, getNodes, fitBounds } = useVueFlow(props.id); @@ -58,6 +59,7 @@ const { nodes: mappedNodes, connections: mappedConnections } = useCanvasMapping( nodes, connections, workflowObject, + renderData, }); const initialFitViewDone = ref(false); // Workaround for https://github.com/bcakmakoglu/vue-flow/issues/1636 @@ -152,6 +154,7 @@ defineExpose({ ref="canvas" :nodes="executing ? mappedNodesThrottled : mappedNodes" :connections="executing ? mappedConnectionsThrottled : mappedConnections" + :render-data="renderData" :event-bus="eventBus" :read-only="readOnly" :can-execute="canExecute" diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/handles/CanvasHandleRenderer.test.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/handles/CanvasHandleRenderer.test.ts index d5fcd587ef6..89b57c348aa 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/handles/CanvasHandleRenderer.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/handles/CanvasHandleRenderer.test.ts @@ -2,15 +2,36 @@ import CanvasHandleRenderer from './CanvasHandleRenderer.vue'; import { NodeConnectionTypes } from 'n8n-workflow'; import { createComponentRenderer } from '@/__tests__/render'; import { CanvasNodeHandleKey } from '@/app/constants'; -import { ref } from 'vue'; -import { CanvasConnectionMode, type CanvasElementPortWithRenderData } from '../../../canvas.types'; +import { ref, type ComputedRef } from 'vue'; +import { + CanvasConnectionMode, + type CanvasConnectionPort, + type CanvasElementPortWithRenderData, +} from '../../../canvas.types'; import { Position } from '@vue-flow/core'; -import { createCanvasProvide } from '@/features/workflows/canvas/__tests__/utils'; +import { + createCanvasNodeProvide, + createCanvasProvide, +} from '@/features/workflows/canvas/__tests__/utils'; + +const renderNodeInputsMap = new Map>(); +const renderNodeOutputsMap = new Map>(); + +vi.mock('@/features/workflows/canvas/canvas.utils', async (importOriginal) => ({ + ...(await importOriginal()), + injectCanvasRenderData: vi.fn(() => ({ + value: { + nodeInputsByNodeId: renderNodeInputsMap, + nodeOutputsByNodeId: renderNodeOutputsMap, + }, + })), +})); const renderComponent = createComponentRenderer(CanvasHandleRenderer, { global: { provide: { ...createCanvasProvide(), + ...createCanvasNodeProvide(), }, }, }); diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/handles/render-types/CanvasHandleMainOutput.test.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/handles/render-types/CanvasHandleMainOutput.test.ts index 0af55123a94..23c8aea46bb 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/handles/render-types/CanvasHandleMainOutput.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/handles/render-types/CanvasHandleMainOutput.test.ts @@ -2,18 +2,40 @@ import CanvasHandleMainOutput from './CanvasHandleMainOutput.vue'; import { createComponentRenderer } from '@/__tests__/render'; import { createCanvasHandleProvide, + createCanvasNodeProvide, createCanvasProvide, } from '@/features/workflows/canvas/__tests__/utils'; +import type { ComputedRef } from 'vue'; +import type { CanvasConnectionPort } from '../../../../canvas.types'; + +const renderNodeInputsMap = new Map>(); +const renderNodeOutputsMap = new Map>(); + +vi.mock('@/features/workflows/canvas/canvas.utils', async (importOriginal) => ({ + ...(await importOriginal()), + injectCanvasRenderData: vi.fn(() => ({ + value: { + nodeInputsByNodeId: renderNodeInputsMap, + nodeOutputsByNodeId: renderNodeOutputsMap, + }, + })), +})); const renderComponent = createComponentRenderer(CanvasHandleMainOutput, { global: { provide: { ...createCanvasProvide(), + ...createCanvasNodeProvide(), }, }, }); describe('CanvasHandleMainOutput', () => { + beforeEach(() => { + renderNodeInputsMap.clear(); + renderNodeOutputsMap.clear(); + }); + it('should render correctly', async () => { const label = 'Test Label'; const { container, getByText, getByTestId } = renderComponent({ diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/handles/render-types/CanvasHandleMainOutput.vue b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/handles/render-types/CanvasHandleMainOutput.vue index 527f955ff46..cc5a0fecdf7 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/handles/render-types/CanvasHandleMainOutput.vue +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/handles/render-types/CanvasHandleMainOutput.vue @@ -2,12 +2,13 @@ import { useCanvasNodeHandle } from '../../../../composables/useCanvasNodeHandle'; import { useCanvasNode } from '../../../../composables/useCanvasNode'; import { computed, ref, useCssModule } from 'vue'; -import type { CanvasNodeDefaultRender } from '../../../../canvas.types'; import { useI18n } from '@n8n/i18n'; import CanvasHandleDot from './parts/CanvasHandleDot.vue'; import CanvasHandlePlus from './parts/CanvasHandlePlus.vue'; import { useCanvas } from '../../../../composables/useCanvas'; import { useZoomAdjustedValues } from '../../../../composables/useZoomAdjustedValues'; +import { getMaxNodePortsLabelSize } from '../../../../canvas.utils'; +import { injectCanvasRenderData } from '@/features/workflows/canvas/canvas.utils'; const emit = defineEmits<{ add: []; @@ -16,7 +17,9 @@ const emit = defineEmits<{ const $style = useCssModule(); const i18n = useI18n(); -const { render } = useCanvasNode(); +const { id } = useCanvasNode(); +const renderData = injectCanvasRenderData(); +const outputs = computed(() => renderData.value.nodeOutputsByNodeId.get(id.value)?.value ?? []); const { label, isConnected, isConnecting, isReadOnly, isRequired, runData } = useCanvasNodeHandle(); const { viewport } = useCanvas(); const { calculateHandleLightness } = useZoomAdjustedValues(viewport); @@ -32,8 +35,6 @@ const classes = computed(() => ({ const isHovered = ref(false); -const renderOptions = computed(() => render.value.options as CanvasNodeDefaultRender['options']); - const runDataTotal = computed(() => runData.value?.total ?? 0); const runDataLabel = computed(() => @@ -49,14 +50,10 @@ const isHandlePlusVisible = computed(() => !isConnecting.value || isHovered.valu const plusType = computed(() => (runDataTotal.value > 0 ? 'success' : 'default')); -const plusLineSize = computed( - () => - ({ - small: 46, - medium: 66, - large: 80, - })[(runDataTotal.value > 0 ? 'large' : renderOptions.value.outputs?.labelSize) ?? 'small'], -); +const plusLineSize = computed(() => { + if (runDataTotal.value > 0) return 80; + return { small: 46, medium: 66, large: 80 }[getMaxNodePortsLabelSize(outputs.value)]; +}); const outputLabelClasses = computed(() => ({ [$style.label]: true, diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/CanvasNode.test.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/CanvasNode.test.ts index ebfd9e7f90b..7c1e4ee9dad 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/CanvasNode.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/CanvasNode.test.ts @@ -2,13 +2,14 @@ import CanvasNode from './CanvasNode.vue'; import { createComponentRenderer } from '@/__tests__/render'; import { createPinia, setActivePinia } from 'pinia'; import { NodeConnectionTypes } from 'n8n-workflow'; +import { computed, type ComputedRef } from 'vue'; import { fireEvent } from '@testing-library/vue'; import { createCanvasNodeData, createCanvasNodeProps, createCanvasProvide, } from '@/features/workflows/canvas/__tests__/utils'; -import { CanvasNodeRenderType } from '../../../canvas.types'; +import { CanvasNodeRenderType, type CanvasConnectionPort } from '../../../canvas.types'; vi.mock('@/app/stores/nodeTypes.store', () => ({ useNodeTypesStore: vi.fn(() => ({ @@ -24,8 +25,23 @@ vi.mock('@/app/stores/nodeTypes.store', () => ({ })), })); +const renderNodeInputsMap = new Map>(); +const renderNodeOutputsMap = new Map>(); + +vi.mock('@/features/workflows/canvas/canvas.utils', async (importOriginal) => ({ + ...(await importOriginal()), + injectCanvasRenderData: vi.fn(() => ({ + value: { + nodeInputsByNodeId: renderNodeInputsMap, + nodeOutputsByNodeId: renderNodeOutputsMap, + }, + })), +})); + let renderComponent: ReturnType; beforeEach(() => { + renderNodeInputsMap.clear(); + renderNodeOutputsMap.clear(); const pinia = createPinia(); setActivePinia(pinia); @@ -65,21 +81,25 @@ describe('CanvasNode', () => { describe('handles', () => { it('should render correct number of input and output handles', async () => { + renderNodeInputsMap.set( + 'node', + computed(() => [ + { type: NodeConnectionTypes.Main, index: 0 }, + { type: NodeConnectionTypes.Main, index: 0 }, + { type: NodeConnectionTypes.Main, index: 0 }, + ]), + ); + renderNodeOutputsMap.set( + 'node', + computed(() => [ + { type: NodeConnectionTypes.Main, index: 0 }, + { type: NodeConnectionTypes.Main, index: 0 }, + ]), + ); + const { getAllByTestId } = renderComponent({ props: { - ...createCanvasNodeProps({ - data: { - inputs: [ - { type: NodeConnectionTypes.Main, index: 0 }, - { type: NodeConnectionTypes.Main, index: 0 }, - { type: NodeConnectionTypes.Main, index: 0 }, - ], - outputs: [ - { type: NodeConnectionTypes.Main, index: 0 }, - { type: NodeConnectionTypes.Main, index: 0 }, - ], - }, - }), + ...createCanvasNodeProps(), }, global: { stubs: { @@ -96,19 +116,23 @@ describe('CanvasNode', () => { }); it('should insert spacers after required non-main input handle', () => { + renderNodeInputsMap.set( + 'node', + computed(() => [ + { type: NodeConnectionTypes.Main, index: 0 }, + { type: NodeConnectionTypes.AiAgent, index: 0, required: true }, + { type: NodeConnectionTypes.AiMemory, index: 0 }, + { type: NodeConnectionTypes.AiTool, index: 0 }, + ]), + ); + renderNodeOutputsMap.set( + 'node', + computed(() => []), + ); + const { getAllByTestId } = renderComponent({ props: { - ...createCanvasNodeProps({ - data: { - inputs: [ - { type: NodeConnectionTypes.Main, index: 0 }, - { type: NodeConnectionTypes.AiAgent, index: 0, required: true }, - { type: NodeConnectionTypes.AiMemory, index: 0 }, - { type: NodeConnectionTypes.AiTool, index: 0 }, - ], - outputs: [], - }, - }), + ...createCanvasNodeProps(), }, global: { stubs: { diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/CanvasNode.vue b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/CanvasNode.vue index 8d331af4258..eaa1150854b 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/CanvasNode.vue +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/CanvasNode.vue @@ -22,6 +22,7 @@ import CanvasNodeRenderer from './CanvasNodeRenderer.vue'; import CanvasHandleRenderer from '../handles/CanvasHandleRenderer.vue'; import { useNodeConnections } from '@/app/composables/useNodeConnections'; import { CanvasNodeKey } from '@/app/constants'; +import { injectCanvasRenderData } from '@/features/workflows/canvas/canvas.utils'; import { useContextMenu } from '@/features/shared/contextMenu/composables/useContextMenu'; import type { NodeProps, XYPosition } from '@vue-flow/core'; import { Position } from '@vue-flow/core'; @@ -79,12 +80,14 @@ const contextMenu = useContextMenu(); const { connectingHandle, isExperimentalNdvActive } = useCanvas(); +const renderData = injectCanvasRenderData(); + /* Toolbar slot classes */ const nodeClasses = ref([]); -const inputs = computed(() => props.data.inputs); -const outputs = computed(() => props.data.outputs); +const inputs = computed(() => renderData.value.nodeInputsByNodeId.get(props.id)?.value ?? []); +const outputs = computed(() => renderData.value.nodeOutputsByNodeId.get(props.id)?.value ?? []); const connections = computed(() => props.data.connections); const { mainInputs, diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/CanvasNodeRenderer.test.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/CanvasNodeRenderer.test.ts index c11b49382ff..4d3ceb73ec4 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/CanvasNodeRenderer.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/CanvasNodeRenderer.test.ts @@ -8,6 +8,16 @@ import { createTestingPinia } from '@pinia/testing'; import { setActivePinia } from 'pinia'; import { CanvasNodeRenderType } from '../../../canvas.types'; +vi.mock('@/features/workflows/canvas/canvas.utils', async (importOriginal) => ({ + ...(await importOriginal()), + injectCanvasRenderData: vi.fn(() => ({ + value: { + nodeInputsByNodeId: new Map(), + nodeOutputsByNodeId: new Map(), + }, + })), +})); + const renderComponent = createComponentRenderer(CanvasNodeRenderer); beforeEach(() => { diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/CanvasNodeToolbar.test.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/CanvasNodeToolbar.test.ts index 7241f4acf5d..1938ffb3698 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/CanvasNodeToolbar.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/CanvasNodeToolbar.test.ts @@ -10,6 +10,16 @@ import { import { CanvasNodeRenderType } from '../../../canvas.types'; import { createPinia, setActivePinia, type Pinia } from 'pinia'; +vi.mock('@/features/workflows/canvas/canvas.utils', async (importOriginal) => ({ + ...(await importOriginal()), + injectCanvasRenderData: vi.fn(() => ({ + value: { + nodeInputsByNodeId: new Map(), + nodeOutputsByNodeId: new Map(), + }, + })), +})); + const renderComponent = createComponentRenderer(CanvasNodeToolbar); describe('CanvasNodeToolbar', () => { diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/CanvasNodeDefault.test.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/CanvasNodeDefault.test.ts index 759899f9e34..0602d6f21a1 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/CanvasNodeDefault.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/CanvasNodeDefault.test.ts @@ -9,10 +9,15 @@ import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; import { createTestingPinia } from '@pinia/testing'; import { fireEvent } from '@testing-library/vue'; import { NodeConnectionTypes } from 'n8n-workflow'; +import { computed, type ComputedRef } from 'vue'; import { setActivePinia } from 'pinia'; import type * as actualVueRouter from 'vue-router'; import { type RouteLocationNormalizedLoadedGeneric, useRoute } from 'vue-router'; -import { CanvasConnectionMode, CanvasNodeRenderType } from '../../../../canvas.types'; +import { + CanvasConnectionMode, + CanvasNodeRenderType, + type CanvasConnectionPort, +} from '../../../../canvas.types'; import CanvasNodeDefault from './CanvasNodeDefault.vue'; vi.mock('vue-router', async (importOriginal) => { @@ -23,6 +28,19 @@ vi.mock('vue-router', async (importOriginal) => { }; }); +const renderNodeInputsMap = new Map>(); +const renderNodeOutputsMap = new Map>(); + +vi.mock('@/features/workflows/canvas/canvas.utils', async (importOriginal) => ({ + ...(await importOriginal()), + injectCanvasRenderData: vi.fn(() => ({ + value: { + nodeInputsByNodeId: renderNodeInputsMap, + nodeOutputsByNodeId: renderNodeOutputsMap, + }, + })), +})); + const stubs = { NodeIcon: { template: @@ -45,6 +63,8 @@ const mockedUseRoute = vi.mocked(useRoute); beforeEach(() => { vi.clearAllMocks(); + renderNodeInputsMap.clear(); + renderNodeOutputsMap.clear(); const pinia = createTestingPinia(); setActivePinia(pinia); nodeTypesStore = mockedStore(useNodeTypesStore); @@ -76,22 +96,30 @@ describe('CanvasNodeDefault', () => { ])( 'should adjust height css variable based on the number of inputs and outputs (%i inputs, %i outputs)', (inputCount, outputCount, expected) => { + renderNodeInputsMap.set( + 'node', + computed(() => + Array.from({ length: inputCount }, () => ({ + type: NodeConnectionTypes.Main, + index: 0, + })), + ), + ); + renderNodeOutputsMap.set( + 'node', + computed(() => + Array.from({ length: outputCount }, () => ({ + type: NodeConnectionTypes.Main, + index: 0, + })), + ), + ); + const { getByText } = renderComponent({ global: { stubs, provide: { - ...createCanvasNodeProvide({ - data: { - inputs: Array.from({ length: inputCount }).map(() => ({ - type: NodeConnectionTypes.Main, - index: 0, - })), - outputs: Array.from({ length: outputCount }).map(() => ({ - type: NodeConnectionTypes.Main, - index: 0, - })), - }, - }), + ...createCanvasNodeProvide(), }, }, }); @@ -187,6 +215,15 @@ describe('CanvasNodeDefault', () => { }); it('should render strike-through when node is disabled and has node input and output handles', () => { + renderNodeInputsMap.set( + 'node', + computed(() => [{ type: NodeConnectionTypes.Main, index: 0 }]), + ); + renderNodeOutputsMap.set( + 'node', + computed(() => [{ type: NodeConnectionTypes.Main, index: 0 }]), + ); + const { container } = renderComponent({ global: { stubs, @@ -194,8 +231,6 @@ describe('CanvasNodeDefault', () => { ...createCanvasNodeProvide({ data: { disabled: true, - inputs: [{ type: NodeConnectionTypes.Main, index: 0 }], - outputs: [{ type: NodeConnectionTypes.Main, index: 0 }], connections: { [CanvasConnectionMode.Input]: { [NodeConnectionTypes.Main]: [ @@ -307,13 +342,20 @@ describe('CanvasNodeDefault', () => { ])( 'should adjust width css variable based on the number of non-main inputs (%s)', (_, nonMainInputs, expected) => { + renderNodeInputsMap.set( + 'node', + computed(() => [ + { type: NodeConnectionTypes.Main, index: 0 }, + ...(nonMainInputs as CanvasConnectionPort[]), + ]), + ); + const { getByText } = renderComponent({ global: { stubs, provide: { ...createCanvasNodeProvide({ data: { - inputs: [{ type: NodeConnectionTypes.Main, index: 0 }, ...nonMainInputs], render: { type: CanvasNodeRenderType.Default, options: { diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/CanvasNodeDefault.vue b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/CanvasNodeDefault.vue index da5916b807c..2950da6d716 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/CanvasNodeDefault.vue +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/CanvasNodeDefault.vue @@ -4,6 +4,7 @@ import { useNodeConnections } from '@/app/composables/useNodeConnections'; import { useI18n } from '@n8n/i18n'; import { useCanvasNode } from '../../../../composables/useCanvasNode'; import type { CanvasNodeDefaultRender } from '../../../../canvas.types'; +import { injectCanvasRenderData } from '@/features/workflows/canvas/canvas.utils'; import { useCanvas } from '../../../../composables/useCanvas'; import { useZoomAdjustedValues } from '../../../../composables/useZoomAdjustedValues'; import CanvasNodeSettingsIcons from './parts/CanvasNodeSettingsIcons.vue'; @@ -34,8 +35,6 @@ const { id, label, subtitle, - inputs, - outputs, connections, isDisabled, isReadOnly, @@ -50,6 +49,9 @@ const { render, isNotInstalledCommunityNode, } = useCanvasNode(); +const renderData = injectCanvasRenderData(); +const inputs = computed(() => renderData.value.nodeInputsByNodeId.get(id.value)?.value ?? []); +const outputs = computed(() => renderData.value.nodeOutputsByNodeId.get(id.value)?.value ?? []); const { mainOutputs, mainOutputConnections, mainInputs, mainInputConnections, nonMainInputs } = useNodeConnections({ inputs, diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/CanvasNodeStickyNote.test.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/CanvasNodeStickyNote.test.ts index 4a975f925e9..f4afdaba2c0 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/CanvasNodeStickyNote.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/CanvasNodeStickyNote.test.ts @@ -5,6 +5,16 @@ import { createTestingPinia } from '@pinia/testing'; import { setActivePinia } from 'pinia'; import { fireEvent } from '@testing-library/vue'; +vi.mock('@/features/workflows/canvas/canvas.utils', async (importOriginal) => ({ + ...(await importOriginal()), + injectCanvasRenderData: vi.fn(() => ({ + value: { + nodeInputsByNodeId: new Map(), + nodeOutputsByNodeId: new Map(), + }, + })), +})); + const renderComponent = createComponentRenderer(CanvasNodeStickyNote); beforeEach(() => { diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeDisabledStrikeThrough.test.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeDisabledStrikeThrough.test.ts index 57947427f0c..8c655048840 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeDisabledStrikeThrough.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeDisabledStrikeThrough.test.ts @@ -1,6 +1,16 @@ import CanvasNodeDisabledStrikeThrough from './CanvasNodeDisabledStrikeThrough.vue'; import { createComponentRenderer } from '@/__tests__/render'; +vi.mock('@/features/workflows/canvas/canvas.utils', async (importOriginal) => ({ + ...(await importOriginal()), + injectCanvasRenderData: vi.fn(() => ({ + value: { + nodeInputsByNodeId: new Map(), + nodeOutputsByNodeId: new Map(), + }, + })), +})); + const renderComponent = createComponentRenderer(CanvasNodeDisabledStrikeThrough); describe('CanvasNodeDisabledStrikeThrough', () => { diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeSettingsIcons.test.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeSettingsIcons.test.ts index 4e2e814cd40..f4a51fe76a3 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeSettingsIcons.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeSettingsIcons.test.ts @@ -21,6 +21,16 @@ vi.mock('@/features/resolvers/composables/useDynamicCredentials', () => ({ useDynamicCredentials: vi.fn(), })); +vi.mock('@/features/workflows/canvas/canvas.utils', async (importOriginal) => ({ + ...(await importOriginal()), + injectCanvasRenderData: vi.fn(() => ({ + value: { + nodeInputsByNodeId: new Map(), + nodeOutputsByNodeId: new Map(), + }, + })), +})); + import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials'; const mockedUseDynamicCredentials = vi.mocked(useDynamicCredentials); diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeStatusIcons.test.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeStatusIcons.test.ts index ae27e13f5c1..c30dc28b729 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeStatusIcons.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeStatusIcons.test.ts @@ -20,6 +20,16 @@ vi.mock('vue-router', async (importOriginal) => { }; }); +vi.mock('@/features/workflows/canvas/canvas.utils', async (importOriginal) => ({ + ...(await importOriginal()), + injectCanvasRenderData: vi.fn(() => ({ + value: { + nodeInputsByNodeId: new Map(), + nodeOutputsByNodeId: new Map(), + }, + })), +})); + const renderComponent = createComponentRenderer(CanvasNodeStatusIcons, { pinia: createTestingPinia(), }); diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeTooltip.test.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeTooltip.test.ts index 62ab85e6e2b..88717ed4ddd 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeTooltip.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeTooltip.test.ts @@ -5,6 +5,16 @@ import type { CanvasNodeDefaultRender } from '../../../../../canvas.types'; import { createCanvasNodeProvide } from '@/features/workflows/canvas/__tests__/utils'; import { waitFor } from '@testing-library/vue'; +vi.mock('@/features/workflows/canvas/canvas.utils', async (importOriginal) => ({ + ...(await importOriginal()), + injectCanvasRenderData: vi.fn(() => ({ + value: { + nodeInputsByNodeId: new Map(), + nodeOutputsByNodeId: new Map(), + }, + })), +})); + const renderComponent = createComponentRenderer(CanvasNodeTooltip); describe('CanvasNodeTooltip', () => { 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 ead1711b22b..e3a3310a5fa 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,5 +1,6 @@ import { useVueFlow, type GraphNode, type VueFlowStore } from '@vue-flow/core'; -import { computed, ref } from 'vue'; +import { computed, ref, shallowRef } from 'vue'; +import type { CanvasRenderData } from '@/features/workflows/canvas/canvas.utils'; import { createCanvasGraphEdge, createCanvasGraphNode, @@ -42,6 +43,10 @@ describe('useCanvasLayout', () => { const { layout } = useCanvasLayout( 'test-canvas-id', computed(() => false), + shallowRef({ + nodeInputsByNodeId: new Map(), + nodeOutputsByNodeId: new Map(), + }), ); return { layout }; @@ -235,12 +240,6 @@ describe('useCanvasLayout', () => { type: CanvasNodeRenderType.Default, options: { configurable: true }, }, - inputs: [ - { type: 'main', index: 0 }, - { type: 'main', index: 1 }, - { type: 'ai_tool', index: 0 }, - ], - outputs: [{ type: 'main', index: 0 }], }, dimensions: undefined, }), @@ -251,8 +250,6 @@ describe('useCanvasLayout', () => { type: CanvasNodeRenderType.Default, options: { configuration: true }, }, - inputs: [{ type: 'main', index: 0 }], - outputs: [{ type: 'main', index: 0 }], }, dimensions: { width: 0, height: 0 }, }), diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasLayout.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasLayout.ts index e647b3871ce..05ee2c083ac 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasLayout.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasLayout.ts @@ -6,11 +6,13 @@ import { CanvasNodeRenderType, type BoundingBox, type CanvasConnection, + type CanvasConnectionPort, type CanvasNodeData, } from '../canvas.types'; import { isPresent } from '@/app/utils/typesUtils'; import { DEFAULT_NODE_SIZE, GRID_SIZE, calculateNodeSize } from '@/app/utils/nodeViewUtils'; -import type { ComputedRef } from 'vue'; +import type { ComputedRef, Ref } from 'vue'; +import type { CanvasRenderData } from '../canvas.utils'; export type CanvasLayoutTarget = 'selection' | 'all'; export type CanvasLayoutSource = @@ -49,7 +51,11 @@ const AI_X_SPACING = GRID_SIZE * 3; const AI_Y_SPACING = GRID_SIZE * 8; const STICKY_BOTTOM_PADDING = GRID_SIZE * 4; -export function useCanvasLayout(canvasId: string, isEmbeddedNdvActive: ComputedRef) { +export function useCanvasLayout( + canvasId: string, + isEmbeddedNdvActive: ComputedRef, + renderData: Ref, +) { const { findNode, findEdge, @@ -108,13 +114,16 @@ export function useCanvasLayout(canvasId: string, isEmbeddedNdvActive: ComputedR node.data.render.type === CanvasNodeRenderType.Default && node.data.render.options.configurable === true; - // Get input/output counts from node data - const mainInputCount = node.data.inputs.filter((input) => input.type === 'main').length || 1; - const mainOutputCount = - node.data.outputs.filter((output) => output.type === 'main').length || 1; + // Get input/output counts from render data (single source of truth) + const inputs: CanvasConnectionPort[] = + renderData.value.nodeInputsByNodeId.get(node.id)?.value ?? []; + const outputs: CanvasConnectionPort[] = + renderData.value.nodeOutputsByNodeId.get(node.id)?.value ?? []; + const mainInputCount = inputs.filter((input) => input.type === 'main').length || 1; + const mainOutputCount = outputs.filter((output) => output.type === 'main').length || 1; const nonMainInputCount = - node.data.inputs.filter((input) => input.type !== 'main').length + - node.data.outputs.filter((output) => output.type !== 'main').length; + inputs.filter((input) => input.type !== 'main').length + + outputs.filter((output) => output.type !== 'main').length; return calculateNodeSize( isConfiguration, 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 a330277e2c1..eda23923c5e 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,8 +1,9 @@ import type { INode, NodeApiError, Workflow } from 'n8n-workflow'; import { NodeConnectionTypes } from 'n8n-workflow'; import { setActivePinia } from 'pinia'; -import type { Ref } from 'vue'; -import { ref } from 'vue'; +import type { ComputedRef, Ref } from 'vue'; +import { computed, ref, shallowRef } from 'vue'; +import type { CanvasRenderData } from '@/features/workflows/canvas/canvas.utils'; import { createTestNode, @@ -29,6 +30,7 @@ import type { IPinData } from 'n8n-workflow'; import { CanvasConnectionMode, CanvasNodeRenderType, + type CanvasConnectionPort, type CanvasNodeDefaultRender, } from '../canvas.types'; import { createCanvasConnectionHandleString, createCanvasConnectionId } from '../canvas.utils'; @@ -63,6 +65,19 @@ vi.mock('@n8n/i18n', async (importOriginal) => ({ }), })); +const renderNodeInputsMap = new Map>(); +const renderNodeOutputsMap = new Map>(); + +const testRenderData = shallowRef({ + nodeInputsByNodeId: renderNodeInputsMap, + nodeOutputsByNodeId: renderNodeOutputsMap, +}); + +const emptyRenderData = shallowRef({ + nodeInputsByNodeId: new Map(), + nodeOutputsByNodeId: new Map(), +}); + vi.mock('@/app/composables/useWorkflowState', async () => { const actual = await vi.importActual('@/app/composables/useWorkflowState'); return { @@ -109,6 +124,9 @@ beforeEach(() => { workflowState = useWorkflowState(); vi.mocked(injectWorkflowState).mockReturnValue(workflowState); + renderNodeInputsMap.clear(); + renderNodeOutputsMap.clear(); + // Set workflow ID so document store can be created const workflowsStore = useWorkflowsStore(); workflowsStore.setWorkflowId('test-workflow'); @@ -126,6 +144,29 @@ function setPinData(pinData: IPinData) { workflowDocumentStore.setPinData(pinData); } +/** + * 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[] = []; @@ -139,6 +180,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedNodes.value).toEqual([]); @@ -164,6 +206,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedNodes.value).toEqual([ @@ -200,20 +243,6 @@ describe('useCanvasMapping', () => { outputMap: {}, visible: false, }, - inputs: [ - { - index: 0, - label: undefined, - type: 'main', - }, - ], - outputs: [ - { - index: 0, - label: undefined, - type: 'main', - }, - ], connections: { [CanvasConnectionMode.Input]: {}, [CanvasConnectionMode.Output]: {}, @@ -228,12 +257,6 @@ describe('useCanvasMapping', () => { type: 'file', }, trigger: true, - inputs: { - labelSize: 'small', - }, - outputs: { - labelSize: 'small', - }, }, }, }, @@ -258,6 +281,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedNodes.value[0]?.data?.disabled).toEqual(true); @@ -282,6 +306,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedNodes.value[0]?.data?.execution.running).toEqual(true); @@ -314,6 +339,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedNodes.value[0]?.data?.connections[CanvasConnectionMode.Output]).toHaveProperty( @@ -366,6 +392,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); const rootStore = mockedStore(useRootStore); @@ -381,12 +408,6 @@ describe('useCanvasMapping', () => { src: 'http://test.local/nodes/test-node/icon.svg', type: 'file', }, - inputs: { - labelSize: 'small', - }, - outputs: { - labelSize: 'small', - }, }, }); }); @@ -408,6 +429,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedNodes.value[0]?.data?.render).toEqual({ @@ -439,6 +461,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedNodes.value[0]?.data?.render).toEqual({ @@ -465,6 +488,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(nodeExecutionRunDataOutputMapById.value).toEqual({}); @@ -495,6 +519,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(nodeExecutionRunDataOutputMapById.value).toEqual({ @@ -556,6 +581,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(nodeExecutionRunDataOutputMapById.value).toEqual({ @@ -628,6 +654,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(nodeExecutionRunDataOutputMapById.value).toEqual({ @@ -688,6 +715,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(nodeExecutionRunDataOutputMapById.value).toEqual({ @@ -738,6 +766,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(nodeExecutionRunDataOutputMapById.value).toEqual({ @@ -818,6 +847,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); // Should have byTarget field for non-main connections @@ -900,6 +930,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); const embeddingOutputData = nodeExecutionRunDataOutputMapById.value[embeddingNode.id]; @@ -931,6 +962,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(additionalNodePropertiesById.value).toEqual({}); }); @@ -950,6 +982,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(additionalNodePropertiesById.value[nodes[0].id]).toEqual({ style: { zIndex: -100 }, @@ -977,6 +1010,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(additionalNodePropertiesById.value[nodes[0].id]).toEqual({ @@ -1008,6 +1042,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(additionalNodePropertiesById.value[nodes[0].id]).toEqual({ @@ -1045,6 +1080,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(additionalNodePropertiesById.value[nodes[0].id]).toEqual({ @@ -1073,6 +1109,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedNodes.value[0]?.data?.issues).toEqual({ @@ -1110,6 +1147,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedNodes.value[0]?.data?.issues).toEqual({ @@ -1146,6 +1184,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedNodes.value[0]?.data?.issues).toEqual({ @@ -1191,6 +1230,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedNodes.value[0]?.data?.issues).toEqual({ @@ -1218,6 +1258,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedNodes.value[0]?.data?.issues).toEqual({ @@ -1258,6 +1299,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedNodes.value[0]?.data?.issues).toEqual({ @@ -1299,6 +1341,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedNodes.value[0]?.data?.issues).toEqual({ @@ -1327,6 +1370,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedNodes.value[0]?.data?.runData?.iterations).toEqual(0); @@ -1375,6 +1419,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedNodes.value[0]?.data?.runData?.iterations).toEqual(2); @@ -1414,6 +1459,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedNodes.value[0]?.data?.runData?.iterations).toEqual(0); @@ -1453,6 +1499,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedNodes.value[0]?.data?.runData?.iterations).toEqual(2); @@ -1483,6 +1530,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedNodes.value[0]?.data?.execution?.status).toEqual('success'); @@ -1518,6 +1566,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedNodes.value[0]?.data?.execution?.status).toEqual('success'); @@ -1546,6 +1595,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedNodes.value[0]?.data?.execution?.status).toEqual('canceled'); @@ -1564,6 +1614,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedNodes.value[0]?.data?.execution?.status).toEqual('new'); @@ -1585,6 +1636,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(nodeHasIssuesById.value[node.id]).toBe(false); @@ -1614,6 +1666,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(nodeHasIssuesById.value[node.id]).toBe(true); @@ -1643,6 +1696,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(nodeHasIssuesById.value[node.id]).toBe(true); @@ -1667,6 +1721,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(nodeHasIssuesById.value[node.id]).toBe(false); @@ -1691,6 +1746,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(nodeHasIssuesById.value[node.id]).toBe(true); @@ -1729,6 +1785,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(nodeHasIssuesById.value[node.id]).toBe(true); @@ -1774,6 +1831,7 @@ describe('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 @@ -1798,6 +1856,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(nodeHasIssuesById.value[node1.id]).toBe(true); // Has error status }); @@ -1832,6 +1891,7 @@ describe('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 @@ -1864,6 +1924,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(nodeExecutionWaitingForNextById.value[node1.id]).toBe(true); @@ -1894,6 +1955,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(nodeExecutionWaitingForNextById.value[node1.id]).toBe(false); @@ -1924,6 +1986,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(nodeExecutionWaitingForNextById.value[node1.id]).toBe(false); @@ -1954,6 +2017,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodesList), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); const renderOptions = mappedNodes.value[0]?.data?.render as CanvasNodeDefaultRender; @@ -1983,6 +2047,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodesList), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); const renderOptions = mappedNodes.value[0]?.data?.render as CanvasNodeDefaultRender; @@ -2012,6 +2077,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodesList), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); const renderOptions = mappedNodes.value[0]?.data?.render as CanvasNodeDefaultRender; @@ -2040,6 +2106,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodesList), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); const renderOptions = mappedNodes.value[0]?.data?.render as CanvasNodeDefaultRender; @@ -2067,6 +2134,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); const source = manualTriggerNode.id; @@ -2137,6 +2205,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); const sourceA = manualTriggerNode.id; @@ -2266,6 +2335,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedConnections.value[0]?.data?.status).toBeUndefined(); @@ -2319,6 +2389,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedConnections.value[0]?.data?.status).toEqual('success'); @@ -2372,6 +2443,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedConnections.value[0]?.data?.status).toBeUndefined(); @@ -2421,6 +2493,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedConnections.value[0]?.data?.status).toEqual('running'); @@ -2447,6 +2520,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedConnections.value[0]?.label).toBe(undefined); @@ -2476,6 +2550,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedConnections.value[0]?.label).toBe('3 items'); @@ -2505,6 +2580,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedConnections.value[0]?.label).toBe('1 item'); @@ -2534,6 +2610,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedConnections.value[0]?.label).toBe(''); @@ -2577,6 +2654,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedConnections.value[0]?.label).toBe('3 items'); @@ -2620,6 +2698,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedConnections.value[0]?.label).toBe('1 item'); @@ -2663,6 +2742,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedConnections.value[0]?.label).toBe(''); @@ -2706,6 +2786,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedConnections.value[0]?.label).toBe('2 items'); @@ -2758,6 +2839,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedConnections.value[0]?.label).toBe('3 items total'); @@ -2816,6 +2898,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedConnections.value[0]?.label).toBe('2 items'); @@ -2862,6 +2945,7 @@ describe('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) @@ -2891,6 +2975,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedConnections.value[0]?.label).toBe(''); @@ -2938,6 +3023,7 @@ describe('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) @@ -2973,6 +3059,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedConnections.value[0]?.data?.status).toEqual('running'); @@ -3018,6 +3105,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedConnections.value[0]?.data?.status).toEqual('pinned'); @@ -3046,6 +3134,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedConnections.value[0]?.data?.status).toEqual('error'); @@ -3089,6 +3178,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedConnections.value[0]?.data?.status).toEqual('success'); @@ -3117,6 +3207,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedConnections.value[0]?.data?.status).toBeUndefined(); @@ -3163,6 +3254,19 @@ describe('useCanvasMapping', () => { }, }; + 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, @@ -3172,13 +3276,13 @@ describe('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 nodeTypesStore = mockedStore(useNodeTypesStore); const manualTriggerNode = mockNode({ name: 'Manual Trigger', type: MANUAL_TRIGGER_NODE_TYPE, @@ -3196,30 +3300,18 @@ describe('useCanvasMapping', () => { }, }; - nodeTypesStore.nodeTypes = { - [MANUAL_TRIGGER_NODE_TYPE]: { - 1: mockNodeTypeDescription({ - name: MANUAL_TRIGGER_NODE_TYPE, - outputs: [ - { - type: NodeConnectionTypes.Main, - maxConnections: 3, - }, - ], - }), + setupRenderNodes([ + { + id: manualTriggerNode.id, + inputs: [{ type: NodeConnectionTypes.Main, index: 0 }], + outputs: [{ type: NodeConnectionTypes.Main, index: 0, maxConnections: 3 }], }, - [SET_NODE_TYPE]: { - 1: mockNodeTypeDescription({ - name: SET_NODE_TYPE, - inputs: [ - { - type: NodeConnectionTypes.Main, - maxConnections: 2, - }, - ], - }), + { + id: setNode.id, + inputs: [{ type: NodeConnectionTypes.Main, index: 0, maxConnections: 2 }], + outputs: [{ type: NodeConnectionTypes.Main, index: 0 }], }, - }; + ]); const workflowObject = createTestWorkflowObject({ nodes, @@ -3230,6 +3322,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: testRenderData, }); expect(mappedConnections.value[0]?.data?.maxConnections).toEqual(2); @@ -3254,6 +3347,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedConnections.value[0]?.data?.maxConnections).toBeUndefined(); @@ -3286,6 +3380,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedConnections.value[0]?.data?.source.node).toEqual(manualTriggerNode.name); @@ -3334,6 +3429,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedConnections.value[0]?.data?.status).toEqual('success'); @@ -3392,6 +3488,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); expect(mappedConnections.value[0]?.data?.status).toEqual('success'); @@ -3437,6 +3534,7 @@ describe('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 @@ -3498,6 +3596,7 @@ describe('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 @@ -3587,6 +3686,7 @@ describe('useCanvasMapping', () => { nodes: ref(nodes), connections: ref(connections), workflowObject: ref(workflowObject) as Ref, + renderData: emptyRenderData, }); // Should have two connections from the model node @@ -3672,6 +3772,7 @@ describe('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 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 00fd683141f..1e1a7fc3a1d 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 @@ -7,19 +7,18 @@ 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 type { CanvasRenderData } from '../canvas.utils'; import type { Ref } from 'vue'; import { ref, computed } from 'vue'; import type { BoundingBox, CanvasConnection, CanvasConnectionData, - CanvasConnectionPort, CanvasNode, CanvasNodeAddNodesRender, CanvasNodeChoicePromptRender, CanvasNodeData, CanvasNodeDefaultRender, - CanvasNodeDefaultRenderLabelSize, CanvasNodeStickyNoteRender, ExecutionOutputMap, } from '../canvas.types'; @@ -27,7 +26,6 @@ import { CanvasConnectionMode, CanvasNodeRenderType } from '../canvas.types'; import { checkOverlap, mapLegacyConnectionsToCanvasConnections, - mapLegacyEndpointsToCanvasConnectionPort, parseCanvasConnectionHandleString, } from '../canvas.utils'; import type { @@ -38,12 +36,7 @@ import type { INodeTypeDescription, ITaskData, } from 'n8n-workflow'; -import { - NodeConnectionTypes, - NodeHelpers, - SEND_AND_WAIT_OPERATION, - WAIT_INDEFINITELY, -} from 'n8n-workflow'; +import { NodeConnectionTypes, SEND_AND_WAIT_OPERATION, WAIT_INDEFINITELY } from 'n8n-workflow'; import type { INodeUi } from '@/Interface'; import { CANVAS_EXECUTION_DATA_THROTTLE_DURATION, @@ -69,10 +62,12 @@ export function useCanvasMapping({ nodes, connections, workflowObject, + renderData, }: { nodes: Ref; connections: Ref; workflowObject: Ref; + renderData: Ref; }) { const i18n = useI18n(); const workflowsStore = useWorkflowsStore(); @@ -128,12 +123,6 @@ export function useCanvasMapping({ node.type, node.typeVersion, ), - inputs: { - labelSize: nodeInputLabelSizeById.value[node.id], - }, - outputs: { - labelSize: nodeOutputLabelSizeById.value[node.id], - }, tooltip: nodeTooltipById.value[node.id], dirtiness: dirtinessByName.value[node.name], icon, @@ -201,89 +190,6 @@ export function useCanvasMapping({ }, {}); }); - const nodeInputsById = computed(() => - nodes.value.reduce>((acc, node) => { - const nodeTypeDescription = nodeTypeDescriptionByNodeId.value[node.id]; - const workflowObjectNode = workflowObject.value.getNode(node.name); - acc[node.id] = - workflowObjectNode && nodeTypeDescription - ? mapLegacyEndpointsToCanvasConnectionPort( - NodeHelpers.getNodeInputs( - workflowObject.value, - workflowObjectNode, - nodeTypeDescription, - ), - nodeTypeDescription.inputNames ?? [], - ) - : []; - - return acc; - }, {}), - ); - - function getLabelSize(label: string = ''): number { - if (label.length <= 2) { - return 0; - } else if (label.length <= 6) { - return 1; - } else { - return 2; - } - } - - function getMaxNodePortsLabelSize( - ports: CanvasConnectionPort[], - ): CanvasNodeDefaultRenderLabelSize { - const labelSizes: CanvasNodeDefaultRenderLabelSize[] = ['small', 'medium', 'large']; - const labelSizeIndexes = ports.reduce( - (sizeAcc, input) => { - if (input.type === NodeConnectionTypes.Main) { - sizeAcc.push(getLabelSize(input.label ?? '')); - } - - return sizeAcc; - }, - [0], - ); - - return labelSizes[Math.max(...labelSizeIndexes)]; - } - - const nodeInputLabelSizeById = computed(() => - nodes.value.reduce>((acc, node) => { - acc[node.id] = getMaxNodePortsLabelSize(nodeInputsById.value[node.id]); - return acc; - }, {}), - ); - - const nodeOutputLabelSizeById = computed(() => - nodes.value.reduce>((acc, node) => { - acc[node.id] = getMaxNodePortsLabelSize(nodeOutputsById.value[node.id]); - return acc; - }, {}), - ); - - const nodeOutputsById = computed(() => - nodes.value.reduce>((acc, node) => { - const nodeTypeDescription = nodeTypeDescriptionByNodeId.value[node.id]; - const workflowObjectNode = workflowObject.value.getNode(node.name); - - acc[node.id] = - workflowObjectNode && nodeTypeDescription - ? mapLegacyEndpointsToCanvasConnectionPort( - NodeHelpers.getNodeOutputs( - workflowObject.value, - workflowObjectNode, - nodeTypeDescription, - ), - nodeTypeDescription.outputNames ?? [], - ) - : []; - - return acc; - }, {}), - ); - const nodePinnedDataById = computed(() => nodes.value.reduce>((acc, node) => { acc[node.id] = workflowDocumentStore.value.getNodePinData(node.name); @@ -693,8 +599,6 @@ export function useCanvasMapping({ type: node.type, typeVersion: node.typeVersion, disabled: node.disabled, - inputs: nodeInputsById.value[node.id] ?? [], - outputs: nodeOutputsById.value[node.id] ?? [], connections: { [CanvasConnectionMode.Input]: inputConnections, [CanvasConnectionMode.Output]: outputConnections, @@ -785,10 +689,9 @@ export function useCanvasMapping({ } } - const maxConnections = [ - ...nodeInputsById.value[connection.source], - ...nodeInputsById.value[connection.target], - ] + const sourceInputs = renderData.value.nodeInputsByNodeId.get(connection.source)?.value ?? []; + const targetInputs = renderData.value.nodeInputsByNodeId.get(connection.target)?.value ?? []; + const maxConnections = [...sourceInputs, ...targetInputs] .filter((port) => port.type === type) .reduce((acc, port) => { if (port.maxConnections === undefined) { diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasNode.test.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasNode.test.ts index 983b8b705a0..5e7501f9f3e 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasNode.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasNode.test.ts @@ -2,7 +2,7 @@ import { useCanvasNode } from './useCanvasNode'; import { inject, ref } from 'vue'; import type { CanvasNodeData, CanvasNodeInjectionData } from '../canvas.types'; import { CanvasConnectionMode, CanvasNodeRenderType } from '../canvas.types'; -import { NodeConnectionTypes } from 'n8n-workflow'; +import { createPinia, setActivePinia } from 'pinia'; vi.mock('vue', async () => { const actual = await vi.importActual('vue'); @@ -13,12 +13,14 @@ vi.mock('vue', async () => { }); describe('useCanvasNode', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + it('should return default values when node is not provided', () => { const result = useCanvasNode(); expect(result.label.value).toBe(''); - expect(result.inputs.value).toEqual([]); - expect(result.outputs.value).toEqual([]); expect(result.connections.value).toEqual({ [CanvasConnectionMode.Input]: {}, [CanvasConnectionMode.Output]: {}, @@ -47,8 +49,6 @@ describe('useCanvasNode', () => { type: 'nodeType1', typeVersion: 1, disabled: true, - inputs: [{ type: NodeConnectionTypes.Main, index: 0 }], - outputs: [{ type: NodeConnectionTypes.Main, index: 0 }], connections: { [CanvasConnectionMode.Input]: { '0': [] }, [CanvasConnectionMode.Output]: {}, @@ -81,8 +81,6 @@ describe('useCanvasNode', () => { expect(result.label.value).toBe('Node 1'); expect(result.name.value).toBe('Node 1'); - expect(result.inputs.value).toEqual([{ type: NodeConnectionTypes.Main, index: 0 }]); - expect(result.outputs.value).toEqual([{ type: NodeConnectionTypes.Main, index: 0 }]); expect(result.connections.value).toEqual({ [CanvasConnectionMode.Input]: { '0': [] }, [CanvasConnectionMode.Output]: {}, diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasNode.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasNode.ts index 815a0b2424a..ff2a916dea9 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasNode.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasNode.ts @@ -17,8 +17,6 @@ export function useCanvasNode() { type: '', typeVersion: 1, disabled: false, - inputs: [], - outputs: [], connections: { [CanvasConnectionMode.Input]: {}, [CanvasConnectionMode.Output]: {} }, issues: { execution: [], validation: [], visible: false }, pinnedData: { count: 0, visible: false }, @@ -38,8 +36,6 @@ export function useCanvasNode() { const subtitle = computed(() => data.value.subtitle); const name = computed(() => data.value.name); - const inputs = computed(() => data.value.inputs); - const outputs = computed(() => data.value.outputs); const connections = computed(() => data.value.connections); const isDisabled = computed(() => data.value.disabled); @@ -83,8 +79,6 @@ export function useCanvasNode() { name, label, subtitle, - inputs, - outputs, connections, isDisabled, isReadOnly, 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 c4b04eed864..9e961a72e02 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 @@ -92,6 +92,7 @@ describe('SyncedWorkflowCanvas', () => { id: 'test-canvas', nodes: [], connections: [], + renderData: { nodeInputsByNodeId: new Map(), nodeOutputsByNodeId: new Map() }, }, }); expect(container).toBeTruthy(); @@ -105,6 +106,7 @@ describe('SyncedWorkflowCanvas', () => { nodes: [], connections: [], applyLayout: true, + renderData: { nodeInputsByNodeId: new Map(), nodeOutputsByNodeId: new Map() }, }, }); @@ -127,6 +129,7 @@ describe('SyncedWorkflowCanvas', () => { nodes: [], connections: [], applyLayout: false, + renderData: { nodeInputsByNodeId: new Map(), nodeOutputsByNodeId: new Map() }, }, }); @@ -145,6 +148,7 @@ describe('SyncedWorkflowCanvas', () => { id: 'test-canvas', nodes: [], connections: [], + renderData: { nodeInputsByNodeId: new Map(), nodeOutputsByNodeId: new Map() }, }, }); @@ -164,6 +168,7 @@ describe('SyncedWorkflowCanvas', () => { nodes: [], connections: [], applyLayout: true, + renderData: { nodeInputsByNodeId: new Map(), nodeOutputsByNodeId: new Map() }, }, }); diff --git a/packages/frontend/editor-ui/src/features/workflows/workflowDiff/SyncedWorkflowCanvas.vue b/packages/frontend/editor-ui/src/features/workflows/workflowDiff/SyncedWorkflowCanvas.vue index 424b0493486..b93efa04fef 100644 --- a/packages/frontend/editor-ui/src/features/workflows/workflowDiff/SyncedWorkflowCanvas.vue +++ b/packages/frontend/editor-ui/src/features/workflows/workflowDiff/SyncedWorkflowCanvas.vue @@ -6,6 +6,7 @@ import type { CanvasEventBusEvents, CanvasNode, } from '@/features/workflows/canvas/canvas.types'; +import type { CanvasRenderData } from '@/features/workflows/canvas/canvas.utils'; import type { CanvasLayoutEvent } from '@/features/workflows/canvas/composables/useCanvasLayout'; import { useVueFlow } from '@vue-flow/core'; import { watch } from 'vue'; @@ -16,6 +17,7 @@ const props = defineProps<{ id: string; nodes: CanvasNode[]; connections: CanvasConnection[]; + renderData: CanvasRenderData; applyLayout?: boolean; }>(); @@ -134,6 +136,7 @@ watch(selectedDetailId, (id) => { :id :nodes :connections + :render-data="renderData" :read-only="true" :event-bus="eventBus" style="width: 100%; height: 100%" 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 0dbe790b047..3a80157a33a 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 @@ -102,6 +102,8 @@ describe('WorkflowDiffContent', () => { isSourceWorkflowNew: false, nodesDiff: new Map(), connectionsDiff: new Map(), + sourceRenderData: { nodeInputsByNodeId: new Map(), nodeOutputsByNodeId: new Map() }, + targetRenderData: { nodeInputsByNodeId: new Map(), nodeOutputsByNodeId: new Map() }, }; describe('panels', () => { diff --git a/packages/frontend/editor-ui/src/features/workflows/workflowDiff/WorkflowDiffContent.vue b/packages/frontend/editor-ui/src/features/workflows/workflowDiff/WorkflowDiffContent.vue index 3b5c9eb85d7..b8fdfb4ba42 100644 --- a/packages/frontend/editor-ui/src/features/workflows/workflowDiff/WorkflowDiffContent.vue +++ b/packages/frontend/editor-ui/src/features/workflows/workflowDiff/WorkflowDiffContent.vue @@ -1,5 +1,6 @@