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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
@@ -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) },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
73
packages/editor-ui/src/composables/useCanvasNode.spec.ts
Normal file
73
packages/editor-ui/src/composables/useCanvasNode.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
69
packages/editor-ui/src/composables/useCanvasNode.ts
Normal file
69
packages/editor-ui/src/composables/useCanvasNode.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user