mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 00:37:10 +02:00
refactor(editor): Add atomic per-field render data composable for canvas nodes (no-changelog) (#30302)
This commit is contained in:
parent
6f365bf3c8
commit
38ea9bb073
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
34
packages/frontend/@n8n/composables/src/structuralComputed.ts
Normal file
34
packages/frontend/@n8n/composables/src/structuralComputed.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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']>;
|
||||
}) {
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) ||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]: {},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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%"
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 }">
|
||||
|
|
|
|||
|
|
@ -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: [] },
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user