refactor(editor): Add atomic per-field render data composable for canvas nodes (no-changelog) (#30302)

This commit is contained in:
Alex Grozav 2026-05-18 13:19:25 +03:00 committed by GitHub
parent 6f365bf3c8
commit 38ea9bb073
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 1450 additions and 333 deletions

View File

@ -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);
});
});

View File

@ -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<T>(
derive: () => T,
isEqual: (a: T, b: T) => boolean = Object.is,
): ComputedRef<T> {
let cached: T;
let primed = false;
return computed<T>(() => {
const next = derive();
if (primed && isEqual(cached, next)) return cached;
cached = next;
primed = true;
return cached;
});
}

View File

@ -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<CanvasNodeData['inputs']>([
const inputs = ref<CanvasConnectionPort[]>([
{ type: NodeConnectionTypes.Main, index: 0 },
{ type: NodeConnectionTypes.Main, index: 1 },
{ type: NodeConnectionTypes.Main, index: 2 },
{ type: NodeConnectionTypes.AiAgent, index: 0 },
]);
const outputs = ref<CanvasNodeData['outputs']>([]);
const outputs = ref<CanvasConnectionPort[]>([]);
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<CanvasNodeData['inputs']>([
const inputs = ref<CanvasConnectionPort[]>([
{ type: NodeConnectionTypes.Main, index: 0 },
{ type: NodeConnectionTypes.AiAgent, index: 0 },
{ type: NodeConnectionTypes.AiAgent, index: 1 },
]);
const outputs = ref<CanvasNodeData['outputs']>([]);
const outputs = ref<CanvasConnectionPort[]>([]);
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<CanvasNodeData['inputs']>([
const inputs = ref<CanvasConnectionPort[]>([
{ type: NodeConnectionTypes.Main, index: 0 },
{ type: NodeConnectionTypes.AiAgent, required: true, index: 0 },
{ type: NodeConnectionTypes.AiAgent, required: false, index: 1 },
]);
const outputs = ref<CanvasNodeData['outputs']>([]);
const outputs = ref<CanvasConnectionPort[]>([]);
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<CanvasNodeData['inputs']>([]);
const outputs = ref<CanvasNodeData['outputs']>([]);
const inputs = ref<CanvasConnectionPort[]>([]);
const outputs = ref<CanvasConnectionPort[]>([]);
const connections = ref<CanvasNodeData['connections']>({
[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<CanvasNodeData['inputs']>([]);
const outputs = ref<CanvasNodeData['outputs']>([
const inputs = ref<CanvasConnectionPort[]>([]);
const outputs = ref<CanvasConnectionPort[]>([
{ 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<CanvasNodeData['inputs']>([]);
const outputs = ref<CanvasNodeData['outputs']>([
const inputs = ref<CanvasConnectionPort[]>([]);
const outputs = ref<CanvasConnectionPort[]>([
{ 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<CanvasNodeData['inputs']>([]);
const outputs = ref<CanvasNodeData['outputs']>([]);
const inputs = ref<CanvasConnectionPort[]>([]);
const outputs = ref<CanvasConnectionPort[]>([]);
const connections = ref<CanvasNodeData['connections']>({
[CanvasConnectionMode.Input]: {},
[CanvasConnectionMode.Output]: {
@ -167,8 +170,8 @@ describe('useNodeConnections', () => {
});
describe('isValidConnection', () => {
const inputs = ref<CanvasNodeData['inputs']>([]);
const outputs = ref<CanvasNodeData['outputs']>([]);
const inputs = ref<CanvasConnectionPort[]>([]);
const outputs = ref<CanvasConnectionPort[]>([]);
const { isValidConnection } = useNodeConnections({
inputs,

View File

@ -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<CanvasNodeData['inputs']>;
outputs: MaybeRef<CanvasNodeData['outputs']>;
inputs: MaybeRef<CanvasConnectionPort[]>;
outputs: MaybeRef<CanvasConnectionPort[]>;
connections: MaybeRef<CanvasNodeData['connections']>;
}) {
/**

View File

@ -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<ShallowRef<ReturnType<typeof useNDVStore> | null>> =
Symbol('NDVStore');
export const CanvasRenderDataKey: InjectionKey<Ref<CanvasRenderData>> = Symbol('CanvasRenderData');
export const ChatHubToolContextKey: InjectionKey<boolean> = Symbol('ChatHubToolContext');
export const AiBuilderScrollToBottomKey: InjectionKey<() => void> = Symbol('ChatScrollToBottom');

View File

@ -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,

View File

@ -36,6 +36,37 @@ const onMyChange = createEventHook<MyChangeEvent>();
- 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`

View File

@ -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];

View File

@ -38,13 +38,17 @@ function createNode(overrides: Partial<INodeUi> = {}): INodeUi {
return createTestNode({ name: 'Test Node', ...overrides }) as INodeUi;
}
function createNodesDeps(): WorkflowDocumentNodesDeps {
function createNodesDeps(
workflowObj?: ReturnType<typeof useWorkflowDocumentWorkflowObject>,
): 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(

View File

@ -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<WorkflowDocumentNodesDeps> = {}): Workflo
syncWorkflowObject: vi.fn(),
unpinNodeData: vi.fn(),
nodeMetadata: useWorkflowDocumentNodeMetadata(),
workflowObject: ref(
mock<Workflow>({ 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> = {},
): 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<Workflow>({
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);
});
});
});
});

View File

@ -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<NodeAddedPayload>
| ChangeEvent<NodeRemovedPayload>
| ChangeEvent<NodeUpdatedPayload>
| ChangeEvent<NodesSetPayload>
| ChangeEvent<NodesResetPayload>;
// --- Deps ---
@ -49,6 +63,7 @@ export interface WorkflowDocumentNodesDeps {
syncWorkflowObject: (nodes: INodeUi[]) => void;
unpinNodeData: (name: string) => void;
nodeMetadata: ReturnType<typeof useWorkflowDocumentNodeMetadata>;
workflowObject: Ref<Workflow>;
}
// --- 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<INodeUi[]>(() => nodes.value);
const nodesByName = computed(() => {
return nodes.value.reduce<Record<string, INodeUi>>((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<Record<string, INodeUi>>({});
const nodesById = shallowRef(new Map<string, INodeUi>());
// 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<string, ComputedRef<CanvasConnectionPort[]>>(),
);
const nodeOutputsByNodeId = shallowReactive(
new Map<string, ComputedRef<CanvasConnectionPort[]>>(),
);
const nodePortScopes = new Map<string, () => 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<Record<string, INodeUi>>((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,

View File

@ -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<string, ComputedRef<CanvasConnectionPort[]>>(),
);
const nodeOutputsByNodeId = shallowReactive(
new Map<string, ComputedRef<CanvasConnectionPort[]>>(),
);
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);
});
});

View File

@ -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<Map<string, ComputedRef<CanvasConnectionPort[]>>>;
nodeOutputsByNodeId: ShallowReactive<Map<string, ComputedRef<CanvasConnectionPort[]>>>;
}
// --- 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,
},
};
}

View File

@ -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', () => {

View File

@ -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}`,
};
}

View File

@ -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;

View File

@ -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<typeof useWorkflowDocumentRenderData>['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<CanvasRenderData> {
return injectStrict(CanvasRenderDataKey);
}
/**
* Maps multiple legacy n8n connections to VueFlow connections
@ -265,6 +289,34 @@ export function insertSpacersBetweenEndpoints<T>(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<number[]>(
(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) ||

View File

@ -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<typeof import('@/features/workflows/canvas/canvas.utils')>()),
injectCanvasRenderData: vi.fn(() => ({
value: {
nodeInputsByNodeId: new Map(),
nodeOutputsByNodeId: new Map(),
},
})),
}));
const canvasId = 'canvas';
let renderComponent: ReturnType<typeof createComponentRenderer>;
@ -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,
},
],
},
}),
];

View File

@ -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<CanvasEventBusEvents>;
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);

View File

@ -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"

View File

@ -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<string, ComputedRef<CanvasConnectionPort[]>>();
const renderNodeOutputsMap = new Map<string, ComputedRef<CanvasConnectionPort[]>>();
vi.mock('@/features/workflows/canvas/canvas.utils', async (importOriginal) => ({
...(await importOriginal<typeof import('@/features/workflows/canvas/canvas.utils')>()),
injectCanvasRenderData: vi.fn(() => ({
value: {
nodeInputsByNodeId: renderNodeInputsMap,
nodeOutputsByNodeId: renderNodeOutputsMap,
},
})),
}));
const renderComponent = createComponentRenderer(CanvasHandleRenderer, {
global: {
provide: {
...createCanvasProvide(),
...createCanvasNodeProvide(),
},
},
});

View File

@ -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<string, ComputedRef<CanvasConnectionPort[]>>();
const renderNodeOutputsMap = new Map<string, ComputedRef<CanvasConnectionPort[]>>();
vi.mock('@/features/workflows/canvas/canvas.utils', async (importOriginal) => ({
...(await importOriginal<typeof import('@/features/workflows/canvas/canvas.utils')>()),
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({

View File

@ -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,

View File

@ -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<string, ComputedRef<CanvasConnectionPort[]>>();
const renderNodeOutputsMap = new Map<string, ComputedRef<CanvasConnectionPort[]>>();
vi.mock('@/features/workflows/canvas/canvas.utils', async (importOriginal) => ({
...(await importOriginal<typeof import('@/features/workflows/canvas/canvas.utils')>()),
injectCanvasRenderData: vi.fn(() => ({
value: {
nodeInputsByNodeId: renderNodeInputsMap,
nodeOutputsByNodeId: renderNodeOutputsMap,
},
})),
}));
let renderComponent: ReturnType<typeof createComponentRenderer>;
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: {

View File

@ -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<string[]>([]);
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,

View File

@ -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<typeof import('@/features/workflows/canvas/canvas.utils')>()),
injectCanvasRenderData: vi.fn(() => ({
value: {
nodeInputsByNodeId: new Map(),
nodeOutputsByNodeId: new Map(),
},
})),
}));
const renderComponent = createComponentRenderer(CanvasNodeRenderer);
beforeEach(() => {

View File

@ -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<typeof import('@/features/workflows/canvas/canvas.utils')>()),
injectCanvasRenderData: vi.fn(() => ({
value: {
nodeInputsByNodeId: new Map(),
nodeOutputsByNodeId: new Map(),
},
})),
}));
const renderComponent = createComponentRenderer(CanvasNodeToolbar);
describe('CanvasNodeToolbar', () => {

View File

@ -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<string, ComputedRef<CanvasConnectionPort[]>>();
const renderNodeOutputsMap = new Map<string, ComputedRef<CanvasConnectionPort[]>>();
vi.mock('@/features/workflows/canvas/canvas.utils', async (importOriginal) => ({
...(await importOriginal<typeof import('@/features/workflows/canvas/canvas.utils')>()),
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: {

View File

@ -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,

View File

@ -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<typeof import('@/features/workflows/canvas/canvas.utils')>()),
injectCanvasRenderData: vi.fn(() => ({
value: {
nodeInputsByNodeId: new Map(),
nodeOutputsByNodeId: new Map(),
},
})),
}));
const renderComponent = createComponentRenderer(CanvasNodeStickyNote);
beforeEach(() => {

View File

@ -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<typeof import('@/features/workflows/canvas/canvas.utils')>()),
injectCanvasRenderData: vi.fn(() => ({
value: {
nodeInputsByNodeId: new Map(),
nodeOutputsByNodeId: new Map(),
},
})),
}));
const renderComponent = createComponentRenderer(CanvasNodeDisabledStrikeThrough);
describe('CanvasNodeDisabledStrikeThrough', () => {

View File

@ -21,6 +21,16 @@ vi.mock('@/features/resolvers/composables/useDynamicCredentials', () => ({
useDynamicCredentials: vi.fn(),
}));
vi.mock('@/features/workflows/canvas/canvas.utils', async (importOriginal) => ({
...(await importOriginal<typeof import('@/features/workflows/canvas/canvas.utils')>()),
injectCanvasRenderData: vi.fn(() => ({
value: {
nodeInputsByNodeId: new Map(),
nodeOutputsByNodeId: new Map(),
},
})),
}));
import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials';
const mockedUseDynamicCredentials = vi.mocked(useDynamicCredentials);

View File

@ -20,6 +20,16 @@ vi.mock('vue-router', async (importOriginal) => {
};
});
vi.mock('@/features/workflows/canvas/canvas.utils', async (importOriginal) => ({
...(await importOriginal<typeof import('@/features/workflows/canvas/canvas.utils')>()),
injectCanvasRenderData: vi.fn(() => ({
value: {
nodeInputsByNodeId: new Map(),
nodeOutputsByNodeId: new Map(),
},
})),
}));
const renderComponent = createComponentRenderer(CanvasNodeStatusIcons, {
pinia: createTestingPinia(),
});

View File

@ -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<typeof import('@/features/workflows/canvas/canvas.utils')>()),
injectCanvasRenderData: vi.fn(() => ({
value: {
nodeInputsByNodeId: new Map(),
nodeOutputsByNodeId: new Map(),
},
})),
}));
const renderComponent = createComponentRenderer(CanvasNodeTooltip);
describe('CanvasNodeTooltip', () => {

View File

@ -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<CanvasRenderData>({
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 },
}),

View File

@ -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<boolean>) {
export function useCanvasLayout(
canvasId: string,
isEmbeddedNdvActive: ComputedRef<boolean>,
renderData: Ref<CanvasRenderData>,
) {
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,

View File

@ -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<string, ComputedRef<CanvasConnectionPort[]>>();
const renderNodeOutputsMap = new Map<string, ComputedRef<CanvasConnectionPort[]>>();
const testRenderData = shallowRef<CanvasRenderData>({
nodeInputsByNodeId: renderNodeInputsMap,
nodeOutputsByNodeId: renderNodeOutputsMap,
});
const emptyRenderData = shallowRef<CanvasRenderData>({
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<Workflow>,
renderData: emptyRenderData,
});
expect(mappedNodes.value).toEqual([]);
@ -164,6 +206,7 @@ describe('useCanvasMapping', () => {
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
renderData: emptyRenderData,
});
expect(nodeExecutionRunDataOutputMapById.value).toEqual({});
@ -495,6 +519,7 @@ describe('useCanvasMapping', () => {
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
renderData: emptyRenderData,
});
expect(nodeExecutionRunDataOutputMapById.value).toEqual({
@ -556,6 +581,7 @@ describe('useCanvasMapping', () => {
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
renderData: emptyRenderData,
});
expect(nodeExecutionRunDataOutputMapById.value).toEqual({
@ -628,6 +654,7 @@ describe('useCanvasMapping', () => {
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
renderData: emptyRenderData,
});
expect(nodeExecutionRunDataOutputMapById.value).toEqual({
@ -688,6 +715,7 @@ describe('useCanvasMapping', () => {
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
renderData: emptyRenderData,
});
expect(nodeExecutionRunDataOutputMapById.value).toEqual({
@ -738,6 +766,7 @@ describe('useCanvasMapping', () => {
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
renderData: emptyRenderData,
});
expect(nodeExecutionRunDataOutputMapById.value).toEqual({
@ -818,6 +847,7 @@ describe('useCanvasMapping', () => {
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
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<Workflow>,
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<Workflow>,
renderData: emptyRenderData,
});
expect(additionalNodePropertiesById.value).toEqual({});
});
@ -950,6 +982,7 @@ describe('useCanvasMapping', () => {
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
renderData: emptyRenderData,
});
const source = manualTriggerNode.id;
@ -2137,6 +2205,7 @@ describe('useCanvasMapping', () => {
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
renderData: emptyRenderData,
});
const sourceA = manualTriggerNode.id;
@ -2266,6 +2335,7 @@ describe('useCanvasMapping', () => {
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
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<Workflow>,
renderData: emptyRenderData,
});
// Should count the 6 items inside response, not just 1 wrapper object

View File

@ -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<INodeUi[]>;
connections: Ref<IConnections>;
workflowObject: Ref<WorkflowObjectAccessors>;
renderData: Ref<CanvasRenderData>;
}) {
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<Record<string, CanvasConnectionPort[]>>((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<number[]>(
(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<Record<string, CanvasNodeDefaultRenderLabelSize>>((acc, node) => {
acc[node.id] = getMaxNodePortsLabelSize(nodeInputsById.value[node.id]);
return acc;
}, {}),
);
const nodeOutputLabelSizeById = computed(() =>
nodes.value.reduce<Record<string, CanvasNodeDefaultRenderLabelSize>>((acc, node) => {
acc[node.id] = getMaxNodePortsLabelSize(nodeOutputsById.value[node.id]);
return acc;
}, {}),
);
const nodeOutputsById = computed(() =>
nodes.value.reduce<Record<string, CanvasConnectionPort[]>>((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<Record<string, INodeExecutionData[] | undefined>>((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<number | undefined>((acc, port) => {
if (port.maxConnections === undefined) {

View File

@ -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]: {},

View File

@ -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,

View File

@ -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() },
},
});

View File

@ -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%"

View File

@ -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', () => {

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import type { CanvasNode, CanvasConnection } from '@/features/workflows/canvas/canvas.types';
import type { CanvasRenderData } from '@/features/workflows/canvas/canvas.utils';
import type { INodeUi } from '@/Interface';
import SyncedWorkflowCanvas from './SyncedWorkflowCanvas.vue';
import WorkflowDiffAside from './WorkflowDiffAside.vue';
@ -14,8 +15,10 @@ import { NodeDiffStatus } from 'n8n-workflow';
const props = defineProps<{
sourceNodes: CanvasNode[];
sourceConnections: CanvasConnection[];
sourceRenderData: CanvasRenderData;
targetNodes: CanvasNode[];
targetConnections: CanvasConnection[];
targetRenderData: CanvasRenderData;
sourceLabel: string;
targetLabel: string;
sourceExists: boolean;
@ -61,6 +64,7 @@ function getEdgeStatusClass(id: string) {
id="top"
:nodes="sourceNodes"
:connections="sourceConnections"
:render-data="sourceRenderData"
:apply-layout="applyLayout"
>
<template #node="{ nodeProps }">
@ -103,6 +107,7 @@ function getEdgeStatusClass(id: string) {
id="bottom"
:nodes="targetNodes"
:connections="targetConnections"
:render-data="targetRenderData"
:apply-layout="applyLayout"
>
<template #node="{ nodeProps }">

View File

@ -57,6 +57,16 @@ vi.mock('@/features/workflows/workflowDiff/useViewportSync', () => ({
}),
}));
vi.mock('@/features/workflows/canvas/canvas.utils', async (importOriginal) => ({
...(await importOriginal<typeof import('@/features/workflows/canvas/canvas.utils')>()),
injectCanvasRenderData: vi.fn(() => ({
value: {
nodeInputsByNodeId: new Map(),
nodeOutputsByNodeId: new Map(),
},
})),
}));
vi.mock('@/features/workflows/workflowDiff/useWorkflowDiff', () => ({
useWorkflowDiff: () => ({
source: { nodes: [], connections: [] },

View File

@ -59,10 +59,11 @@ const rootStore = useRootStore();
const i18n = useI18n();
const toast = useToast();
const { source, target, nodesDiff, connectionsDiff } = useWorkflowDiff(
computed(() => removeWorkflowExecutionData(props.sourceWorkflow)),
computed(() => removeWorkflowExecutionData(props.targetWorkflow)),
);
const { source, target, sourceRenderData, targetRenderData, nodesDiff, connectionsDiff } =
useWorkflowDiff(
computed(() => removeWorkflowExecutionData(props.sourceWorkflow)),
computed(() => removeWorkflowExecutionData(props.targetWorkflow)),
);
// Use shared composable for UI logic
const {
@ -277,8 +278,10 @@ const onNodeChangeSelect = (change: { node: INodeUi; status: NodeDiffStatus }) =
<WorkflowDiffContent
:source-nodes="source.nodes"
:source-connections="source.connections"
:source-render-data="sourceRenderData"
:target-nodes="target.nodes"
:target-connections="target.connections"
:target-render-data="targetRenderData"
:source-label="sourceLabel"
:target-label="targetLabel"
:source-exists="!!sourceWorkflow"

View File

@ -23,11 +23,19 @@ const mockDocumentStore = vi.hoisted(() => ({
nodes: [],
connections: {},
}),
hydrate: vi.fn(),
render: {
nodeInputsByNodeId: new Map(),
nodeOutputsByNodeId: new Map(),
},
$id: 'test-store',
$dispose: vi.fn(),
}));
vi.mock('@/app/stores/workflowDocument.store', () => ({
useWorkflowDocumentStore: () => mockDocumentStore,
createWorkflowDocumentId: vi.fn().mockReturnValue('test-id'),
injectWorkflowDocumentStore: () => ({ value: mockDocumentStore }),
disposeWorkflowDocumentStore: vi.fn(),
}));
vi.mock('@/app/stores/nodeTypes.store', () => ({

View File

@ -1,12 +1,18 @@
import type { CanvasConnection, CanvasNode } from '@/features/workflows/canvas/canvas.types';
import type { INodeUi, IWorkflowDb } from '@/Interface';
import type { MaybeRefOrGetter, Ref, ComputedRef } from 'vue';
import { toValue, computed, ref, watchEffect, shallowRef } from 'vue';
import { toValue, computed, ref, watchEffect, shallowRef, onScopeDispose } from 'vue';
import { useCanvasMapping } from '@/features/workflows/canvas/composables/useCanvasMapping';
import type { Workflow, IConnections, INodeTypeDescription, NodeDiff } from 'n8n-workflow';
import { compareWorkflowsNodes, NodeDiffStatus } from 'n8n-workflow';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import {
injectWorkflowDocumentStore,
useWorkflowDocumentStore,
createWorkflowDocumentId,
disposeWorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
import type { CanvasRenderData } from '@/features/workflows/canvas/canvas.utils';
export function mapConnections(connections: CanvasConnection[]) {
return connections.reduce(
@ -59,6 +65,7 @@ function createWorkflowDiff(
workflowNodes: Ref<INodeUi[]>,
workflowConnections: Ref<IConnections>,
workflowObjectRef: Ref<Workflow>,
renderData: Ref<CanvasRenderData>,
) {
// Call useCanvasMapping at setup time, not inside computed
// This is required because useCanvasMapping uses inject() internally
@ -66,6 +73,7 @@ function createWorkflowDiff(
nodes: workflowNodes,
connections: workflowConnections,
workflowObject: workflowObjectRef,
renderData,
});
const canvasData = computed(() => {
@ -102,6 +110,39 @@ function createWorkflowDiff(
};
}
function createDiffRenderData(workflowRef: ComputedRef<IWorkflowDb | undefined>, side: string) {
const renderData = shallowRef<CanvasRenderData>({
nodeInputsByNodeId: new Map(),
nodeOutputsByNodeId: new Map(),
});
let workflowDocumentStore: ReturnType<typeof useWorkflowDocumentStore> | null = null;
watchEffect(() => {
const wf = workflowRef.value;
if (!wf?.id) return;
if (workflowDocumentStore) {
disposeWorkflowDocumentStore(workflowDocumentStore);
}
const versionId = wf.versionId ?? `diff-${side}`;
const docId = createWorkflowDocumentId(wf.id, versionId);
workflowDocumentStore = useWorkflowDocumentStore(docId);
workflowDocumentStore.hydrate({ ...wf, versionId } as IWorkflowDb);
renderData.value = workflowDocumentStore.render;
});
function dispose() {
if (workflowDocumentStore) {
disposeWorkflowDocumentStore(workflowDocumentStore);
workflowDocumentStore = null;
}
}
return { renderData, dispose };
}
export const useWorkflowDiff = (
sourceWorkflow: MaybeRefOrGetter<IWorkflowDb | undefined>,
targetWorkflow: MaybeRefOrGetter<IWorkflowDb | undefined>,
@ -118,11 +159,21 @@ export const useWorkflowDiff = (
workflowDocumentStore.value.createWorkflowObject,
);
const { renderData: sourceRenderData, dispose: disposeSource } = createDiffRenderData(
sourceRefs.workflowRef,
'source',
);
const { renderData: targetRenderData, dispose: disposeTarget } = createDiffRenderData(
targetRefs.workflowRef,
'target',
);
const sourceDiff = createWorkflowDiff(
sourceRefs.workflowRef,
sourceRefs.workflowNodes,
sourceRefs.workflowConnections,
sourceRefs.workflowObjectRef,
sourceRenderData,
);
const targetDiff = createWorkflowDiff(
@ -130,8 +181,14 @@ export const useWorkflowDiff = (
targetRefs.workflowNodes,
targetRefs.workflowConnections,
targetRefs.workflowObjectRef,
targetRenderData,
);
onScopeDispose(() => {
disposeSource();
disposeTarget();
});
// Expose canvas data as source/target for backwards compatibility
const source = sourceDiff.canvasData;
const target = targetDiff.canvasData;
@ -215,6 +272,8 @@ export const useWorkflowDiff = (
return {
source,
target,
sourceRenderData,
targetRenderData,
nodesDiff,
connectionsDiff,
};