feat(editor): Add execute workflow functionality and statuses to new canvas (no-changelog) (#9902)

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
Co-authored-by: Oleg Ivaniv <me@olegivaniv.com>
This commit is contained in:
Alex Grozav
2024-07-08 13:25:18 +03:00
committed by GitHub
parent 1807835740
commit 8f970b5d37
33 changed files with 1394 additions and 330 deletions

View File

@@ -21,6 +21,7 @@ import { useRouter } from 'vue-router';
import { mock } from 'vitest-mock-extended';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
vi.mock('vue-router', async () => {
const actual = await import('vue-router');
@@ -39,11 +40,12 @@ describe('useCanvasOperations', () => {
let nodeTypesStore: ReturnType<typeof useNodeTypesStore>;
let credentialsStore: ReturnType<typeof useCredentialsStore>;
let canvasOperations: ReturnType<typeof useCanvasOperations>;
let workflowHelpers: ReturnType<typeof useWorkflowHelpers>;
const lastClickPosition = ref<XYPosition>([450, 450]);
const router = useRouter();
beforeEach(() => {
beforeEach(async () => {
const pinia = createPinia();
setActivePinia(pinia);
@@ -53,15 +55,17 @@ describe('useCanvasOperations', () => {
historyStore = useHistoryStore();
nodeTypesStore = useNodeTypesStore();
credentialsStore = useCredentialsStore();
workflowHelpers = useWorkflowHelpers({ router });
const workflowId = 'test';
workflowsStore.workflowsById[workflowId] = mock<IWorkflowDb>({
const workflow = mock<IWorkflowDb>({
id: workflowId,
nodes: [],
tags: [],
usedCredentials: [],
});
workflowsStore.initializeEditableWorkflow(workflowId);
workflowsStore.workflowsById[workflowId] = workflow;
await workflowHelpers.initState(workflow, true);
canvasOperations = useCanvasOperations({ router, lastClickPosition });
});
@@ -506,13 +510,13 @@ describe('useCanvasOperations', () => {
connection: [
{
index: 0,
node: 'Node B',
type: 'main',
node: 'Node A',
type: NodeConnectionType.Main,
},
{
index: 0,
node: 'spy',
type: 'main',
node: 'Node B',
type: NodeConnectionType.Main,
},
],
});
@@ -567,6 +571,8 @@ describe('useCanvasOperations', () => {
name: 'Node B',
});
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValueOnce(nodeA).mockReturnValueOnce(nodeB);
const connection: Connection = {
source: nodeA.id,
sourceHandle: `outputs/${NodeConnectionType.Main}/0`,
@@ -574,7 +580,14 @@ describe('useCanvasOperations', () => {
targetHandle: `inputs/${NodeConnectionType.Main}/0`,
};
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValueOnce(nodeA).mockReturnValueOnce(nodeB);
const nodeTypeDescription = mockNodeTypeDescription({
name: 'node',
inputs: [NodeConnectionType.Main],
});
nodeTypesStore.setNodeTypes([nodeTypeDescription]);
canvasOperations.editableWorkflowObject.value.nodes[nodeA.name] = nodeA;
canvasOperations.editableWorkflowObject.value.nodes[nodeB.name] = nodeB;
canvasOperations.createConnection(connection);
@@ -588,6 +601,189 @@ describe('useCanvasOperations', () => {
});
});
describe('isConnectionAllowed', () => {
it('should return false if source and target nodes are the same', () => {
const node = mockNode({ id: '1', type: 'testType', name: 'Test Node' });
expect(canvasOperations.isConnectionAllowed(node, node, NodeConnectionType.Main)).toBe(false);
});
it('should return false if target node type does not have inputs', () => {
const sourceNode = mockNode({
id: '1',
type: 'sourceType',
name: 'Source Node',
});
const targetNode = mockNode({
id: '2',
type: 'targetType',
name: 'Target Node',
});
const nodeTypeDescription = mockNodeTypeDescription({
name: 'targetType',
inputs: [],
});
nodeTypesStore.setNodeTypes([nodeTypeDescription]);
expect(
canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main),
).toBe(false);
});
it('should return false if target node does not exist in the workflow', () => {
const sourceNode = mockNode({
id: '1',
type: 'sourceType',
name: 'Source Node',
});
const targetNode = mockNode({
id: '2',
type: 'targetType',
name: 'Target Node',
});
const nodeTypeDescription = mockNodeTypeDescription({
name: 'targetType',
inputs: [NodeConnectionType.Main],
});
nodeTypesStore.setNodeTypes([nodeTypeDescription]);
expect(
canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main),
).toBe(false);
});
it('should return false if input type does not match connection type', () => {
const sourceNode = mockNode({
id: '1',
type: 'sourceType',
name: 'Source Node',
});
const targetNode = mockNode({
id: '2',
type: 'targetType',
name: 'Target Node',
});
const nodeTypeDescription = mockNodeTypeDescription({
name: 'targetType',
inputs: [NodeConnectionType.AiTool],
});
nodeTypesStore.setNodeTypes([nodeTypeDescription]);
canvasOperations.editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode;
canvasOperations.editableWorkflowObject.value.nodes[targetNode.name] = targetNode;
expect(
canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main),
).toBe(false);
});
it('should return false if source node type is not allowed by target node input filter', () => {
const sourceNode = mockNode({
id: '1',
type: 'sourceType',
name: 'Source Node',
typeVersion: 1,
});
const targetNode = mockNode({
id: '2',
type: 'targetType',
name: 'Target Node',
typeVersion: 1,
});
const nodeTypeDescription = mockNodeTypeDescription({
name: 'targetType',
inputs: [
{
type: NodeConnectionType.Main,
filter: {
nodes: ['allowedType'],
},
},
],
});
nodeTypesStore.setNodeTypes([nodeTypeDescription]);
canvasOperations.editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode;
canvasOperations.editableWorkflowObject.value.nodes[targetNode.name] = targetNode;
expect(
canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main),
).toBe(false);
});
it('should return true if all conditions including filter are met', () => {
const sourceNode = mockNode({
id: '1',
type: 'sourceType',
name: 'Source Node',
typeVersion: 1,
});
const targetNode = mockNode({
id: '2',
type: 'targetType',
name: 'Target Node',
typeVersion: 1,
});
const nodeTypeDescription = mockNodeTypeDescription({
name: 'targetType',
inputs: [
{
type: NodeConnectionType.Main,
filter: {
nodes: ['sourceType'],
},
},
],
});
nodeTypesStore.setNodeTypes([nodeTypeDescription]);
canvasOperations.editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode;
canvasOperations.editableWorkflowObject.value.nodes[targetNode.name] = targetNode;
expect(
canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main),
).toBe(true);
});
it('should return true if all conditions are met and no filter is set', () => {
const sourceNode = mockNode({
id: '1',
type: 'sourceType',
name: 'Source Node',
typeVersion: 1,
});
const targetNode = mockNode({
id: '2',
type: 'targetType',
name: 'Target Node',
typeVersion: 1,
});
const nodeTypeDescription = mockNodeTypeDescription({
name: 'targetType',
inputs: [
{
type: NodeConnectionType.Main,
},
],
});
nodeTypesStore.setNodeTypes([nodeTypeDescription]);
canvasOperations.editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode;
canvasOperations.editableWorkflowObject.value.nodes[targetNode.name] = targetNode;
expect(
canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main),
).toBe(true);
});
});
describe('deleteConnection', () => {
it('should not delete a connection if source node does not exist', () => {
const removeConnectionSpy = vi

View File

@@ -7,24 +7,27 @@ import { mock } from 'vitest-mock-extended';
import { useCanvasMapping } from '@/composables/useCanvasMapping';
import type { IWorkflowDb } from '@/Interface';
import { createTestWorkflowObject, mockNode, mockNodes } from '@/__tests__/mocks';
import { MANUAL_TRIGGER_NODE_TYPE } from '@/constants';
vi.mock('@/stores/nodeTypes.store', () => ({
useNodeTypesStore: vi.fn(() => ({
getNodeType: vi.fn(() => ({
name: 'test',
description: 'Test Node Description',
})),
isTriggerNode: vi.fn(),
isConfigNode: vi.fn(),
isConfigurableNode: vi.fn(),
})),
}));
import {
createTestWorkflowObject,
mockNode,
mockNodes,
mockNodeTypeDescription,
} from '@/__tests__/mocks';
import { MANUAL_TRIGGER_NODE_TYPE, SET_NODE_TYPE } from '@/constants';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
beforeEach(() => {
const pinia = createPinia();
setActivePinia(pinia);
useNodeTypesStore().setNodeTypes([
mockNodeTypeDescription({
name: MANUAL_TRIGGER_NODE_TYPE,
}),
mockNodeTypeDescription({
name: SET_NODE_TYPE,
}),
]);
});
afterEach(() => {
@@ -75,13 +78,41 @@ describe('useCanvasMapping', () => {
type: manualTriggerNode.type,
typeVersion: expect.anything(),
disabled: false,
inputs: [],
outputs: [],
execution: {
status: 'new',
waiting: undefined,
},
issues: {
items: [],
visible: false,
},
pinnedData: {
count: 0,
visible: false,
},
runData: {
count: 0,
visible: false,
},
inputs: [
{
index: 0,
label: undefined,
type: 'main',
},
],
outputs: [
{
index: 0,
label: undefined,
type: 'main',
},
],
connections: {
input: {},
output: {},
},
renderType: 'default',
renderType: 'trigger',
},
},
]);
@@ -173,6 +204,7 @@ describe('useCanvasMapping', () => {
index: 0,
type: NodeConnectionType.Main,
},
status: undefined,
target: {
index: 0,
type: NodeConnectionType.Main,
@@ -219,6 +251,7 @@ describe('useCanvasMapping', () => {
index: 0,
type: NodeConnectionType.AiTool,
},
status: undefined,
target: {
index: 0,
type: NodeConnectionType.AiTool,
@@ -239,6 +272,7 @@ describe('useCanvasMapping', () => {
index: 0,
type: NodeConnectionType.AiDocument,
},
status: undefined,
target: {
index: 1,
type: NodeConnectionType.AiDocument,

View File

@@ -1,9 +1,16 @@
/**
* Canvas V2 Only
* @TODO Remove this notice when Canvas V2 is the only one in use
*/
import { useI18n } from '@/composables/useI18n';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { Ref } from 'vue';
import { computed } from 'vue';
import type {
CanvasConnection,
CanvasConnectionData,
CanvasConnectionPort,
CanvasElement,
CanvasElementData,
@@ -12,9 +19,17 @@ import {
mapLegacyConnectionsToCanvasConnections,
mapLegacyEndpointsToCanvasConnectionPort,
} from '@/utils/canvasUtilsV2';
import type { Workflow } from 'n8n-workflow';
import type {
ExecutionStatus,
ExecutionSummary,
INodeExecutionData,
ITaskData,
Workflow,
} from 'n8n-workflow';
import { NodeHelpers } from 'n8n-workflow';
import type { IWorkflowDb } from '@/Interface';
import { WAIT_TIME_UNLIMITED } from '@/constants';
import { sanitizeHtml } from '@/utils/htmlUtils';
export function useCanvasMapping({
workflow,
@@ -23,7 +38,8 @@ export function useCanvasMapping({
workflow: Ref<IWorkflowDb>;
workflowObject: Ref<Workflow>;
}) {
const locale = useI18n();
const i18n = useI18n();
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const renderTypeByNodeType = computed(
@@ -87,6 +103,97 @@ export function useCanvasMapping({
}, {}),
);
const nodePinnedDataById = computed(() =>
workflow.value.nodes.reduce<Record<string, INodeExecutionData[] | undefined>>((acc, node) => {
acc[node.id] = workflowsStore.pinDataByNodeName(node.name);
return acc;
}, {}),
);
const nodeExecutionStatusById = computed(() =>
workflow.value.nodes.reduce<Record<string, ExecutionStatus>>((acc, node) => {
acc[node.id] =
workflowsStore.getWorkflowRunData?.[node.name]?.filter(Boolean)[0].executionStatus ?? 'new';
return acc;
}, {}),
);
const nodeExecutionRunDataById = computed(() =>
workflow.value.nodes.reduce<Record<string, ITaskData[] | null>>((acc, node) => {
acc[node.id] = workflowsStore.getWorkflowResultDataByNodeName(node.name);
return acc;
}, {}),
);
const nodeIssuesById = computed(() =>
workflow.value.nodes.reduce<Record<string, string[]>>((acc, node) => {
const issues: string[] = [];
const nodeExecutionRunData = workflowsStore.getWorkflowRunData?.[node.name];
if (nodeExecutionRunData) {
nodeExecutionRunData.forEach((executionRunData) => {
if (executionRunData?.error) {
const { message, description } = executionRunData.error;
const issue = `${message}${description ? ` (${description})` : ''}`;
issues.push(sanitizeHtml(issue));
}
});
}
if (node?.issues !== undefined) {
issues.push(...NodeHelpers.nodeIssuesToString(node.issues, node));
}
acc[node.id] = issues;
return acc;
}, {}),
);
const nodeHasIssuesById = computed(() =>
workflow.value.nodes.reduce<Record<string, boolean>>((acc, node) => {
if (['crashed', 'error'].includes(nodeExecutionStatusById.value[node.id])) {
acc[node.id] = true;
} else if (nodePinnedDataById.value[node.id]) {
acc[node.id] = false;
} else {
acc[node.id] = Object.keys(node?.issues ?? {}).length > 0;
}
return acc;
}, {}),
);
const nodeExecutionWaitingById = computed(() =>
workflow.value.nodes.reduce<Record<string, string | undefined>>((acc, node) => {
const isExecutionSummary = (execution: object): execution is ExecutionSummary =>
'waitTill' in execution;
const workflowExecution = workflowsStore.getWorkflowExecution;
const lastNodeExecuted = workflowExecution?.data?.resultData?.lastNodeExecuted;
if (workflowExecution && lastNodeExecuted && isExecutionSummary(workflowExecution)) {
if (node.name === workflowExecution.data?.resultData?.lastNodeExecuted) {
const waitDate = new Date(workflowExecution.waitTill as Date);
if (waitDate.toISOString() === WAIT_TIME_UNLIMITED) {
acc[node.id] = i18n.baseText(
'node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall',
);
}
acc[node.id] = i18n.baseText('node.nodeIsWaitingTill', {
interpolate: {
date: waitDate.toLocaleDateString(),
time: waitDate.toLocaleTimeString(),
},
});
}
}
return acc;
}, {}),
);
const elements = computed<CanvasElement[]>(() => [
...workflow.value.nodes.map<CanvasElement>((node) => {
const inputConnections = workflowObject.value.connectionsByDestinationNode[node.name] ?? {};
@@ -103,6 +210,22 @@ export function useCanvasMapping({
input: inputConnections,
output: outputConnections,
},
issues: {
items: nodeIssuesById.value[node.id],
visible: nodeHasIssuesById.value[node.id],
},
pinnedData: {
count: nodePinnedDataById.value[node.id]?.length ?? 0,
visible: !!nodePinnedDataById.value[node.id],
},
execution: {
status: nodeExecutionStatusById.value[node.id],
waiting: nodeExecutionWaitingById.value[node.id],
},
runData: {
count: nodeExecutionRunDataById.value[node.id]?.length ?? 0,
visible: !!nodeExecutionRunDataById.value[node.id],
},
renderType: renderTypeByNodeType.value[node.type] ?? 'default',
};
@@ -125,26 +248,63 @@ export function useCanvasMapping({
return mappedConnections.map((connection) => {
const type = getConnectionType(connection);
const label = getConnectionLabel(connection);
const data = getConnectionData(connection);
return {
...connection,
data,
type,
label,
};
});
});
function getConnectionData(connection: CanvasConnection): CanvasConnectionData {
const fromNode = workflow.value.nodes.find(
(node) => node.name === connection.data?.fromNodeName,
);
let status: CanvasConnectionData['status'];
if (fromNode) {
if (nodePinnedDataById.value[fromNode.id] && nodeExecutionRunDataById.value[fromNode.id]) {
status = 'pinned';
} else if (nodeHasIssuesById.value[fromNode.id]) {
status = 'error';
} else if (nodeExecutionRunDataById.value[fromNode.id]) {
status = 'success';
}
}
return {
...(connection.data as CanvasConnectionData),
status,
};
}
function getConnectionType(_: CanvasConnection): string {
return 'canvas-edge';
}
function getConnectionLabel(connection: CanvasConnection): string {
const pinData = workflow.value.pinData?.[connection.data?.fromNodeName ?? ''];
const fromNode = workflow.value.nodes.find(
(node) => node.name === connection.data?.fromNodeName,
);
if (pinData?.length) {
return locale.baseText('ndv.output.items', {
adjustToNumber: pinData.length,
interpolate: { count: String(pinData.length) },
if (!fromNode) {
return '';
}
if (nodePinnedDataById.value[fromNode.id]) {
const pinnedDataCount = nodePinnedDataById.value[fromNode.id]?.length ?? 0;
return i18n.baseText('ndv.output.items', {
adjustToNumber: pinnedDataCount,
interpolate: { count: String(pinnedDataCount) },
});
} else if (nodeExecutionRunDataById.value[fromNode.id]) {
const runDataCount = nodeExecutionRunDataById.value[fromNode.id]?.length ?? 0;
return i18n.baseText('ndv.output.items', {
adjustToNumber: runDataCount,
interpolate: { count: String(runDataCount) },
});
}

View File

@@ -0,0 +1,73 @@
import { useCanvasNode } from '@/composables/useCanvasNode';
import { inject, ref } from 'vue';
vi.mock('vue', async () => {
const actual = await vi.importActual('vue');
return {
...actual,
inject: vi.fn(),
};
});
describe('useCanvasNode', () => {
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({ input: {}, output: {} });
expect(result.isDisabled.value).toBe(false);
expect(result.isSelected.value).toBeUndefined();
expect(result.pinnedDataCount.value).toBe(0);
expect(result.hasPinnedData.value).toBe(false);
expect(result.runDataCount.value).toBe(0);
expect(result.hasRunData.value).toBe(false);
expect(result.issues.value).toEqual([]);
expect(result.hasIssues.value).toBe(false);
expect(result.executionStatus.value).toBeUndefined();
expect(result.executionWaiting.value).toBeUndefined();
});
it('should return node data when node is provided', () => {
const node = {
data: {
value: {
id: 'node1',
type: 'nodeType1',
typeVersion: 1,
disabled: true,
inputs: ['input1'],
outputs: ['output1'],
connections: { input: { '0': ['node2'] }, output: {} },
issues: { items: ['issue1'], visible: true },
execution: { status: 'running', waiting: false },
runData: { count: 1, visible: true },
pinnedData: { count: 1, visible: true },
renderType: 'default',
},
},
label: ref('Node 1'),
selected: ref(true),
};
vi.mocked(inject).mockReturnValue(node);
const result = useCanvasNode();
expect(result.label.value).toBe('Node 1');
expect(result.inputs.value).toEqual(['input1']);
expect(result.outputs.value).toEqual(['output1']);
expect(result.connections.value).toEqual({ input: { '0': ['node2'] }, output: {} });
expect(result.isDisabled.value).toBe(true);
expect(result.isSelected.value).toBe(true);
expect(result.pinnedDataCount.value).toBe(1);
expect(result.hasPinnedData.value).toBe(true);
expect(result.runDataCount.value).toBe(1);
expect(result.hasRunData.value).toBe(true);
expect(result.issues.value).toEqual(['issue1']);
expect(result.hasIssues.value).toBe(true);
expect(result.executionStatus.value).toBe('running');
expect(result.executionWaiting.value).toBe(false);
});
});

View File

@@ -0,0 +1,69 @@
/**
* Canvas V2 Only
* @TODO Remove this notice when Canvas V2 is the only one in use
*/
import { CanvasNodeKey } from '@/constants';
import { computed, inject } from 'vue';
import type { CanvasElementData } from '@/types';
export function useCanvasNode() {
const node = inject(CanvasNodeKey);
const data = computed<CanvasElementData>(
() =>
node?.data.value ?? {
id: '',
type: '',
typeVersion: 1,
disabled: false,
inputs: [],
outputs: [],
connections: { input: {}, output: {} },
issues: { items: [], visible: false },
pinnedData: { count: 0, visible: false },
execution: {},
runData: { count: 0, visible: false },
renderType: 'default',
},
);
const label = computed(() => node?.label.value ?? '');
const inputs = computed(() => data.value.inputs);
const outputs = computed(() => data.value.outputs);
const connections = computed(() => data.value.connections);
const isDisabled = computed(() => data.value.disabled);
const isSelected = computed(() => node?.selected.value);
const pinnedDataCount = computed(() => data.value.pinnedData.count);
const hasPinnedData = computed(() => data.value.pinnedData.count > 0);
const issues = computed(() => data.value.issues.items ?? []);
const hasIssues = computed(() => data.value.issues.visible);
const executionStatus = computed(() => data.value.execution.status);
const executionWaiting = computed(() => data.value.execution.waiting);
const runDataCount = computed(() => data.value.runData.count);
const hasRunData = computed(() => data.value.runData.visible);
return {
node,
label,
inputs,
outputs,
connections,
isDisabled,
isSelected,
pinnedDataCount,
hasPinnedData,
runDataCount,
hasRunData,
issues,
hasIssues,
executionStatus,
executionWaiting,
};
}

View File

@@ -1,4 +1,10 @@
/**
* Canvas V2 Only
* @TODO Remove this notice when Canvas V2 is the only one in use
*/
import type { CanvasElement } from '@/types';
import { CanvasConnectionMode } from '@/types';
import type {
AddedNodesAndConnections,
INodeUi,
@@ -170,9 +176,9 @@ export function useCanvasOperations({
historyStore.startRecordingUndo();
}
workflowsStore.removeNodeById(id);
workflowsStore.removeNodeConnectionsById(id);
workflowsStore.removeNodeExecutionDataById(id);
workflowsStore.removeNodeById(id);
if (trackHistory) {
historyStore.pushCommandToUndo(new RemoveNodeCommand(node));
@@ -215,7 +221,7 @@ export function useCanvasOperations({
return;
}
ndvStore.activeNodeName = node.name;
setNodeActiveByName(node.name);
}
function setNodeActiveByName(name: string) {
@@ -334,18 +340,32 @@ export function useCanvasOperations({
const outputIndex = lastSelectedNodeOutputIndex ?? 0;
const targetEndpoint = lastSelectedNodeEndpointUuid ?? '';
// Handle connection of scoped_endpoint types
// Create a connection between the last selected node and the new one
if (lastSelectedNode && !options.isAutoAdd) {
// If we have a specific endpoint to connect to
if (lastSelectedNodeEndpointUuid) {
const { type: connectionType } = parseCanvasConnectionHandleString(
const { type: connectionType, mode } = parseCanvasConnectionHandleString(
lastSelectedNodeEndpointUuid,
);
if (isConnectionAllowed(lastSelectedNode, newNodeData, connectionType)) {
const newNodeId = newNodeData.id;
const newNodeHandle = `${CanvasConnectionMode.Input}/${connectionType}/0`;
const lasSelectedNodeId = lastSelectedNode.id;
const lastSelectedNodeHandle = targetEndpoint;
if (mode === CanvasConnectionMode.Input) {
createConnection({
source: lastSelectedNode.id,
sourceHandle: targetEndpoint,
target: newNodeData.id,
targetHandle: `inputs/${connectionType}/0`,
source: newNodeId,
sourceHandle: newNodeHandle,
target: lasSelectedNodeId,
targetHandle: lastSelectedNodeHandle,
});
} else {
createConnection({
source: lasSelectedNodeId,
sourceHandle: lastSelectedNodeHandle,
target: newNodeId,
targetHandle: newNodeHandle,
});
}
} else {
@@ -510,8 +530,6 @@ export function useCanvasOperations({
canvasStore.newNodeInsertPosition = null;
} else {
let yOffset = 0;
const workflow = workflowsStore.getCurrentWorkflow();
if (lastSelectedConnection) {
const sourceNodeType = nodeTypesStore.getNodeType(
lastSelectedNode.type,
@@ -526,7 +544,7 @@ export function useCanvasOperations({
];
const sourceNodeOutputs = NodeHelpers.getNodeOutputs(
workflow,
editableWorkflowObject.value,
lastSelectedNode,
sourceNodeType,
);
@@ -553,7 +571,11 @@ export function useCanvasOperations({
// outputs here is to calculate the position, it is fine to assume
// that they have no outputs and are so treated as a regular node
// with only "main" outputs.
outputs = NodeHelpers.getNodeOutputs(workflow, newNodeData, nodeTypeDescription);
outputs = NodeHelpers.getNodeOutputs(
editableWorkflowObject.value,
newNodeData,
nodeTypeDescription,
);
} catch (e) {}
const outputTypes = NodeHelpers.getConnectionTypes(outputs);
const lastSelectedNodeType = nodeTypesStore.getNodeType(
@@ -566,13 +588,15 @@ export function useCanvasOperations({
outputTypes.length > 0 &&
outputTypes.every((outputName) => outputName !== NodeConnectionType.Main)
) {
const lastSelectedNodeWorkflow = workflow.getNode(lastSelectedNode.name);
const lastSelectedNodeWorkflow = editableWorkflowObject.value.getNode(
lastSelectedNode.name,
);
if (!lastSelectedNodeWorkflow || !lastSelectedNodeType) {
return;
}
const lastSelectedInputs = NodeHelpers.getNodeInputs(
workflow,
editableWorkflowObject.value,
lastSelectedNodeWorkflow,
lastSelectedNodeType,
);
@@ -600,7 +624,7 @@ export function useCanvasOperations({
// Has only main outputs or no outputs at all
const inputs = NodeHelpers.getNodeInputs(
workflow,
editableWorkflowObject.value,
lastSelectedNode,
lastSelectedNodeType,
);
@@ -683,10 +707,11 @@ export function useCanvasOperations({
{ trackHistory = false }: { trackHistory?: boolean },
) {
const sourceNode = workflowsStore.nodesByName[sourceNodeName];
const workflow = workflowHelpers.getCurrentWorkflow();
const checkNodes = workflowHelpers.getConnectedNodes('downstream', workflow, sourceNodeName);
const checkNodes = workflowHelpers.getConnectedNodes(
'downstream',
editableWorkflowObject.value,
sourceNodeName,
);
for (const nodeName of checkNodes) {
const node = workflowsStore.nodesByName[nodeName];
const oldPosition = node.position;
@@ -784,6 +809,9 @@ export function useCanvasOperations({
connection: mappedConnection,
});
nodeHelpers.updateNodeInputIssues(sourceNode);
nodeHelpers.updateNodeInputIssues(targetNode);
uiStore.stateIsDirty = true;
}
@@ -829,46 +857,54 @@ export function useCanvasOperations({
function isConnectionAllowed(
sourceNode: INodeUi,
targetNode: INodeUi,
targetNodeConnectionType: NodeConnectionType,
connectionType: NodeConnectionType,
): boolean {
const targetNodeType = nodeTypesStore.getNodeType(targetNode.type, targetNode.typeVersion);
if (sourceNode.id === targetNode.id) {
return false;
}
const targetNodeType = nodeTypesStore.getNodeType(targetNode.type, targetNode.typeVersion);
if (targetNodeType?.inputs?.length) {
const workflow = workflowsStore.getCurrentWorkflow();
const workflowNode = workflow.getNode(targetNode.name);
const workflowNode = editableWorkflowObject.value.getNode(targetNode.name);
if (!workflowNode) {
return false;
}
let inputs: Array<ConnectionTypes | INodeInputConfiguration> = [];
if (targetNodeType) {
inputs = NodeHelpers.getNodeInputs(workflow, workflowNode, targetNodeType) || [];
inputs =
NodeHelpers.getNodeInputs(editableWorkflowObject.value, workflowNode, targetNodeType) ||
[];
}
let targetHasConnectionTypeAsInput = false;
for (const input of inputs) {
if (typeof input === 'string' || input.type !== targetNodeConnectionType || !input.filter) {
// No filters defined or wrong connection type
continue;
}
const inputType = typeof input === 'string' ? input : input.type;
if (inputType === connectionType) {
if (typeof input === 'object' && 'filter' in input && input.filter?.nodes.length) {
if (!input.filter.nodes.includes(sourceNode.type)) {
// this.dropPrevented = true;
toast.showToast({
title: i18n.baseText('nodeView.showError.nodeNodeCompatible.title'),
message: i18n.baseText('nodeView.showError.nodeNodeCompatible.message', {
interpolate: { sourceNodeName: sourceNode.name, targetNodeName: targetNode.name },
}),
type: 'error',
duration: 5000,
});
if (input.filter.nodes.length) {
if (!input.filter.nodes.includes(sourceNode.type)) {
// this.dropPrevented = true;
toast.showToast({
title: i18n.baseText('nodeView.showError.nodeNodeCompatible.title'),
message: i18n.baseText('nodeView.showError.nodeNodeCompatible.message', {
interpolate: { sourceNodeName: sourceNode.name, targetNodeName: targetNode.name },
}),
type: 'error',
duration: 5000,
});
return false;
return false;
}
}
targetHasConnectionTypeAsInput = true;
}
}
return targetHasConnectionTypeAsInput;
}
return sourceNode.id !== targetNode.id;
return false;
}
async function addConnections(
@@ -907,5 +943,6 @@ export function useCanvasOperations({
createConnection,
deleteConnection,
revertDeleteConnection,
isConnectionAllowed,
};
}

View File

@@ -229,22 +229,27 @@ export function useNodeHelpers() {
};
}
function updateNodeInputIssues(node: INodeUi): void {
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
if (!nodeType) {
return;
}
const workflow = workflowsStore.getCurrentWorkflow();
const nodeInputIssues = getNodeInputIssues(workflow, node, nodeType);
workflowsStore.setNodeIssue({
node: node.name,
type: 'input',
value: nodeInputIssues?.input ? nodeInputIssues.input : null,
});
}
function updateNodesInputIssues() {
const nodes = workflowsStore.allNodes;
const workflow = workflowsStore.getCurrentWorkflow();
for (const node of nodes) {
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
if (!nodeType) {
return;
}
const nodeInputIssues = getNodeInputIssues(workflow, node, nodeType);
workflowsStore.setNodeIssue({
node: node.name,
type: 'input',
value: nodeInputIssues?.input ? nodeInputIssues.input : null,
});
updateNodeInputIssues(node);
}
}
@@ -260,6 +265,14 @@ export function useNodeHelpers() {
}
}
function updateNodesParameterIssues() {
const nodes = workflowsStore.allNodes;
for (const node of nodes) {
updateNodeParameterIssues(node);
}
}
function updateNodeCredentialIssuesByName(name: string): void {
const node = workflowsStore.getNodeByName(name);
@@ -1228,6 +1241,8 @@ export function useNodeHelpers() {
getNodeIssues,
updateNodesInputIssues,
updateNodesExecutionIssues,
updateNodesParameterIssues,
updateNodeInputIssues,
updateNodeCredentialIssuesByName,
updateNodeCredentialIssues,
updateNodeParameterIssuesByName,

View File

@@ -1050,8 +1050,12 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
}
}
async function initState(workflowData: IWorkflowDb): Promise<void> {
async function initState(workflowData: IWorkflowDb, set = false): Promise<void> {
workflowsStore.addWorkflow(workflowData);
if (set) {
workflowsStore.setWorkflow(workflowData);
}
workflowsStore.setActive(workflowData.active || false);
workflowsStore.setWorkflowId(workflowData.id);
workflowsStore.setWorkflowName({