feat(editor): Add ability to import workflows in new canvas (no-changelog) (#10051)

Co-authored-by: Elias Meire <elias@meire.dev>
This commit is contained in:
Alex Grozav
2024-07-18 11:59:11 +03:00
committed by GitHub
parent 1f420e0bd6
commit 45affe5d89
12 changed files with 1296 additions and 810 deletions

View File

@@ -9,45 +9,45 @@ import type {
ROLE, ROLE,
} from '@/constants'; } from '@/constants';
import type { IMenuItem, NodeCreatorTag } from 'n8n-design-system'; import type { IMenuItem, NodeCreatorTag } from 'n8n-design-system';
import { import type {
type GenericValue, GenericValue,
type IConnections, IConnections,
type ICredentialsDecrypted, ICredentialsDecrypted,
type ICredentialsEncrypted, ICredentialsEncrypted,
type ICredentialType, ICredentialType,
type IDataObject, IDataObject,
type INode, INode,
type INodeIssues, INodeIssues,
type INodeParameters, INodeParameters,
type INodeTypeDescription, INodeTypeDescription,
type IPinData, IPinData,
type IRunExecutionData, IRunExecutionData,
type IRun, IRun,
type IRunData, IRunData,
type ITaskData, ITaskData,
type IWorkflowSettings as IWorkflowSettingsWorkflow, IWorkflowSettings as IWorkflowSettingsWorkflow,
type WorkflowExecuteMode, WorkflowExecuteMode,
type PublicInstalledPackage, PublicInstalledPackage,
type INodeTypeNameVersion, INodeTypeNameVersion,
type ILoadOptions, ILoadOptions,
type INodeCredentials, INodeCredentials,
type INodeListSearchItems, INodeListSearchItems,
type NodeParameterValueType, NodeParameterValueType,
type IDisplayOptions, IDisplayOptions,
type ExecutionSummary, ExecutionSummary,
type FeatureFlags, FeatureFlags,
type ExecutionStatus, ExecutionStatus,
type ITelemetryTrackProperties, ITelemetryTrackProperties,
type IUserManagementSettings, IUserManagementSettings,
type WorkflowSettings, WorkflowSettings,
type IUserSettings, IUserSettings,
type IN8nUISettings, IN8nUISettings,
type BannerName, BannerName,
type INodeExecutionData, INodeExecutionData,
type INodeProperties, INodeProperties,
type NodeConnectionType, NodeConnectionType,
type INodeCredentialsDetails, INodeCredentialsDetails,
type StartNodeData, StartNodeData,
} from 'n8n-workflow'; } from 'n8n-workflow';
import type { BulkCommand, Undoable } from '@/models/history'; import type { BulkCommand, Undoable } from '@/models/history';
import type { PartialBy, TupleToUnion } from '@/utils/typeHelpers'; import type { PartialBy, TupleToUnion } from '@/utils/typeHelpers';
@@ -1805,9 +1805,7 @@ export type AddedNode = {
type: string; type: string;
openDetail?: boolean; openDetail?: boolean;
isAutoAdd?: boolean; isAutoAdd?: boolean;
name?: string; } & Partial<INodeUi>;
position?: XYPosition;
};
export type AddedNodeConnection = { export type AddedNodeConnection = {
from: { nodeIndex: number; outputIndex?: number }; from: { nodeIndex: number; outputIndex?: number };

View File

@@ -48,10 +48,18 @@ const props = withDefaults(
}, },
); );
const { getSelectedEdges, getSelectedNodes, viewportRef, fitView, project } = useVueFlow({ const { getSelectedEdges, getSelectedNodes, viewportRef, fitView, project, onPaneReady } =
id: props.id, useVueFlow({
id: props.id,
});
onPaneReady(async () => {
await onFitView();
paneReady.value = true;
}); });
const paneReady = ref(false);
/** /**
* Nodes * Nodes
*/ */
@@ -183,7 +191,7 @@ function onClickPane(event: MouseEvent) {
} }
async function onFitView() { async function onFitView() {
await fitView(); await fitView({ maxZoom: 1.2, padding: 0.1 });
} }
/** /**
@@ -207,12 +215,12 @@ onUnmounted(() => {
:nodes="nodes" :nodes="nodes"
:edges="connections" :edges="connections"
:apply-changes="false" :apply-changes="false"
fit-view-on-init
pan-on-scroll pan-on-scroll
snap-to-grid snap-to-grid
:snap-grid="[16, 16]" :snap-grid="[16, 16]"
:min-zoom="0.2" :min-zoom="0.2"
:max-zoom="2" :max-zoom="4"
:class="[$style.canvas, { [$style.visible]: paneReady }]"
data-test-id="canvas" data-test-id="canvas"
@node-drag-stop="onNodeDragStop" @node-drag-stop="onNodeDragStop"
@selection-drag-stop="onSelectionDragStop" @selection-drag-stop="onSelectionDragStop"
@@ -253,10 +261,21 @@ onUnmounted(() => {
data-test-id="canvas-controls" data-test-id="canvas-controls"
:class="$style.canvasControls" :class="$style.canvasControls"
:position="controlsPosition" :position="controlsPosition"
@fit-view="onFitView"
></Controls> ></Controls>
</VueFlow> </VueFlow>
</template> </template>
<style lang="scss" module>
.canvas {
opacity: 0;
&.visible {
opacity: 1;
}
}
</style>
<style lang="scss"> <style lang="scss">
.vue-flow__controls { .vue-flow__controls {
display: flex; display: flex;

View File

@@ -61,15 +61,114 @@ describe('useCanvasOperations', () => {
const workflow = mock<IWorkflowDb>({ const workflow = mock<IWorkflowDb>({
id: workflowId, id: workflowId,
nodes: [], nodes: [],
connections: {},
tags: [], tags: [],
usedCredentials: [], usedCredentials: [],
}); });
workflowsStore.workflowsById[workflowId] = workflow;
workflowsStore.resetWorkflow();
workflowsStore.resetState();
await workflowHelpers.initState(workflow); await workflowHelpers.initState(workflow);
canvasOperations = useCanvasOperations({ router, lastClickPosition }); canvasOperations = useCanvasOperations({ router, lastClickPosition });
}); });
describe('addNode', () => {
it('should throw error when node type does not exist', async () => {
vi.spyOn(nodeTypesStore, 'getNodeTypes').mockResolvedValue(undefined);
await expect(canvasOperations.addNode({ type: 'nonexistent' })).rejects.toThrow();
});
it('should create node with default version when version is undefined', async () => {
nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type' })]);
const result = await canvasOperations.addNode({
name: 'example',
type: 'type',
});
expect(result.typeVersion).toBe(1);
});
it('should create node with last version when version is an array', async () => {
nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type', version: [1, 2] })]);
const result = await canvasOperations.addNode({
type: 'type',
});
expect(result.typeVersion).toBe(2);
});
it('should create node with default position when position is not provided', async () => {
nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type' })]);
const result = await canvasOperations.addNode({
type: 'type',
});
expect(result.position).toEqual([460, 460]); // Default last click position
});
it('should create node with provided position when position is provided', async () => {
nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type' })]);
const result = await canvasOperations.addNode({
type: 'type',
position: [20, 20],
});
expect(result.position).toEqual([20, 20]);
});
it('should create node with default credentials when only one credential is available', async () => {
const credential = mock<ICredentialsResponse>({ id: '1', name: 'cred', type: 'cred' });
const nodeTypeName = 'type';
nodeTypesStore.setNodeTypes([
mockNodeTypeDescription({ name: nodeTypeName, credentials: [{ name: credential.name }] }),
]);
credentialsStore.addCredentials([credential]);
// @ts-expect-error Known pinia issue when spying on store getters
vi.spyOn(credentialsStore, 'getUsableCredentialByType', 'get').mockReturnValue(() => [
credential,
]);
const result = await canvasOperations.addNode({
type: nodeTypeName,
});
expect(result.credentials).toEqual({ [credential.name]: { id: '1', name: credential.name } });
});
it('should not assign credentials when multiple credentials are available', async () => {
const credentialA = mock<ICredentialsResponse>({ id: '1', name: 'credA', type: 'cred' });
const credentialB = mock<ICredentialsResponse>({ id: '1', name: 'credB', type: 'cred' });
const nodeTypeName = 'type';
nodeTypesStore.setNodeTypes([
mockNodeTypeDescription({
name: nodeTypeName,
credentials: [{ name: credentialA.name }, { name: credentialB.name }],
}),
]);
// @ts-expect-error Known pinia issue when spying on store getters
vi.spyOn(credentialsStore, 'getUsableCredentialByType', 'get').mockReturnValue(() => [
credentialA,
credentialB,
]);
const result = await canvasOperations.addNode({
type: 'type',
});
expect(result.credentials).toBeUndefined();
});
});
describe('updateNodePosition', () => { describe('updateNodePosition', () => {
it('should update node position', () => { it('should update node position', () => {
const setNodePositionByIdSpy = vi const setNodePositionByIdSpy = vi
@@ -123,104 +222,6 @@ describe('useCanvasOperations', () => {
}); });
}); });
describe('initializeNodeDataWithDefaultCredentials', () => {
it('should throw error when node type does not exist', async () => {
vi.spyOn(nodeTypesStore, 'getNodeTypes').mockResolvedValue(undefined);
await expect(
canvasOperations.initializeNodeDataWithDefaultCredentials({ type: 'nonexistent' }),
).rejects.toThrow();
});
it('should create node with default version when version is undefined', async () => {
nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type' })]);
const result = await canvasOperations.initializeNodeDataWithDefaultCredentials({
name: 'example',
type: 'type',
});
expect(result.typeVersion).toBe(1);
});
it('should create node with last version when version is an array', async () => {
nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type', version: [1, 2] })]);
const result = await canvasOperations.initializeNodeDataWithDefaultCredentials({
type: 'type',
});
expect(result.typeVersion).toBe(2);
});
it('should create node with default position when position is not provided', async () => {
nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type' })]);
const result = await canvasOperations.initializeNodeDataWithDefaultCredentials({
type: 'type',
});
expect(result.position).toEqual([0, 0]);
});
it('should create node with provided position when position is provided', async () => {
nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type' })]);
const result = await canvasOperations.initializeNodeDataWithDefaultCredentials({
type: 'type',
position: [10, 20],
});
expect(result.position).toEqual([10, 20]);
});
it('should create node with default credentials when only one credential is available', async () => {
const credential = mock<ICredentialsResponse>({ id: '1', name: 'cred', type: 'cred' });
const nodeTypeName = 'type';
nodeTypesStore.setNodeTypes([
mockNodeTypeDescription({ name: nodeTypeName, credentials: [{ name: credential.name }] }),
]);
credentialsStore.addCredentials([credential]);
// @ts-expect-error Known pinia issue when spying on store getters
vi.spyOn(credentialsStore, 'getUsableCredentialByType', 'get').mockReturnValue(() => [
credential,
]);
const result = await canvasOperations.initializeNodeDataWithDefaultCredentials({
type: nodeTypeName,
});
expect(result.credentials).toEqual({ [credential.name]: { id: '1', name: credential.name } });
});
it('should not assign credentials when multiple credentials are available', async () => {
const credentialA = mock<ICredentialsResponse>({ id: '1', name: 'credA', type: 'cred' });
const credentialB = mock<ICredentialsResponse>({ id: '1', name: 'credB', type: 'cred' });
const nodeTypeName = 'type';
nodeTypesStore.setNodeTypes([
mockNodeTypeDescription({
name: nodeTypeName,
credentials: [{ name: credentialA.name }, { name: credentialB.name }],
}),
]);
// @ts-expect-error Known pinia issue when spying on store getters
vi.spyOn(credentialsStore, 'getUsableCredentialByType', 'get').mockReturnValue(() => [
credentialA,
credentialB,
]);
const result = await canvasOperations.initializeNodeDataWithDefaultCredentials({
type: 'type',
});
expect(result.credentials).toBeUndefined();
});
});
describe('addNodes', () => { describe('addNodes', () => {
it('should add nodes at specified positions', async () => { it('should add nodes at specified positions', async () => {
const nodeTypeName = 'type'; const nodeTypeName = 'type';
@@ -489,6 +490,7 @@ describe('useCanvasOperations', () => {
const nodes = [ const nodes = [
mockNode({ id: 'a', name: 'Node A', type: nodeTypeName, position: [40, 40] }), mockNode({ id: 'a', name: 'Node A', type: nodeTypeName, position: [40, 40] }),
mockNode({ id: 'b', name: 'Node B', type: nodeTypeName, position: [40, 40] }), mockNode({ id: 'b', name: 'Node B', type: nodeTypeName, position: [40, 40] }),
mockNode({ id: 'c', name: 'Node C', type: nodeTypeName, position: [40, 40] }),
]; ];
nodeTypesStore.setNodeTypes([ nodeTypesStore.setNodeTypes([
@@ -504,14 +506,27 @@ describe('useCanvasOperations', () => {
.mockReturnValueOnce(nodes[1]); .mockReturnValueOnce(nodes[1]);
const connections = [ const connections = [
{ from: { nodeIndex: 0, outputIndex: 0 }, to: { nodeIndex: 1, inputIndex: 0 } }, {
{ from: { nodeIndex: 1, outputIndex: 0 }, to: { nodeIndex: 2, inputIndex: 0 } }, source: nodes[0].id,
target: nodes[1].id,
data: {
source: { type: NodeConnectionType.Main, index: 0 },
target: { type: NodeConnectionType.Main, index: 0 },
},
},
{
source: nodes[1].id,
target: nodes[2].id,
data: {
source: { type: NodeConnectionType.Main, index: 0 },
target: { type: NodeConnectionType.Main, index: 0 },
},
},
]; ];
const offsetIndex = 0;
const addConnectionSpy = vi.spyOn(workflowsStore, 'addConnection'); const addConnectionSpy = vi.spyOn(workflowsStore, 'addConnection');
await canvasOperations.addConnections(connections, { offsetIndex }); await canvasOperations.addConnections(connections);
expect(addConnectionSpy).toHaveBeenCalledWith({ expect(addConnectionSpy).toHaveBeenCalledWith({
connection: [ connection: [

File diff suppressed because it is too large Load Diff

View File

@@ -1261,6 +1261,7 @@ export function useNodeHelpers() {
deleteJSPlumbConnection, deleteJSPlumbConnection,
loadNodesProperties, loadNodesProperties,
addNodes, addNodes,
addConnections,
addConnection, addConnection,
removeConnection, removeConnection,
removeConnectionByConnectionInfo, removeConnectionByConnectionInfo,

View File

@@ -65,6 +65,7 @@ import type { useRouter } from 'vue-router';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import { useTagsStore } from '@/stores/tags.store'; import { useTagsStore } from '@/stores/tags.store';
import useWorkflowsEEStore from '@/stores/workflows.ee.store';
export function resolveParameter<T = IDataObject>( export function resolveParameter<T = IDataObject>(
parameter: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[], parameter: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[],
@@ -439,6 +440,7 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
const rootStore = useRootStore(); const rootStore = useRootStore();
const templatesStore = useTemplatesStore(); const templatesStore = useTemplatesStore();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const workflowsEEStore = useWorkflowsEEStore();
const uiStore = useUIStore(); const uiStore = useUIStore();
const nodeHelpers = useNodeHelpers(); const nodeHelpers = useNodeHelpers();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
@@ -1063,6 +1065,17 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
workflowsStore.setWorkflowVersionId(workflowData.versionId); workflowsStore.setWorkflowVersionId(workflowData.versionId);
workflowsStore.setWorkflowMetadata(workflowData.meta); workflowsStore.setWorkflowMetadata(workflowData.meta);
if (workflowData.usedCredentials) {
workflowsStore.setUsedCredentials(workflowData.usedCredentials);
}
if (workflowData.sharedWithProjects) {
workflowsEEStore.setWorkflowSharedWith({
workflowId: workflowData.id,
sharedWithProjects: workflowData.sharedWithProjects,
});
}
const tags = (workflowData.tags ?? []) as ITag[]; const tags = (workflowData.tags ?? []) as ITag[];
const tagIds = tags.map((tag) => tag.id); const tagIds = tags.map((tag) => tag.id);
workflowsStore.setWorkflowTagIds(tagIds || []); workflowsStore.setWorkflowTagIds(tagIds || []);

View File

@@ -203,8 +203,10 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
// canvasStore.newNodeInsertPosition = null; // canvasStore.newNodeInsertPosition = null;
if (isVueFlowConnection(connection)) { if (isVueFlowConnection(connection)) {
uiStore.lastSelectedNodeConnection = connection; uiStore.lastInteractedWithNodeConnection = connection;
} }
uiStore.lastInteractedWithNodeHandle = connection.sourceHandle ?? null;
uiStore.lastInteractedWithNodeId = sourceNode.id;
openNodeCreator({ openNodeCreator({
source: eventSource, source: eventSource,

View File

@@ -178,7 +178,6 @@ export const useUIStore = defineStore(STORES.UI, () => {
const lastSelectedNode = ref<string | null>(null); const lastSelectedNode = ref<string | null>(null);
const lastSelectedNodeOutputIndex = ref<number | null>(null); const lastSelectedNodeOutputIndex = ref<number | null>(null);
const lastSelectedNodeEndpointUuid = ref<string | null>(null); const lastSelectedNodeEndpointUuid = ref<string | null>(null);
const lastSelectedNodeConnection = ref<Connection | null>(null);
const nodeViewOffsetPosition = ref<[number, number]>([0, 0]); const nodeViewOffsetPosition = ref<[number, number]>([0, 0]);
const nodeViewMoveInProgress = ref<boolean>(false); const nodeViewMoveInProgress = ref<boolean>(false);
const selectedNodes = ref<INodeUi[]>([]); const selectedNodes = ref<INodeUi[]>([]);
@@ -189,6 +188,11 @@ export const useUIStore = defineStore(STORES.UI, () => {
const pendingNotificationsForViews = ref<{ [key in VIEWS]?: NotificationOptions[] }>({}); const pendingNotificationsForViews = ref<{ [key in VIEWS]?: NotificationOptions[] }>({});
const isCreateNodeActive = ref<boolean>(false); const isCreateNodeActive = ref<boolean>(false);
// Last interacted with - Canvas v2 specific
const lastInteractedWithNodeConnection = ref<Connection | null>(null);
const lastInteractedWithNodeHandle = ref<string | null>(null);
const lastInteractedWithNodeId = ref<string | null>(null);
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const rootStore = useRootStore(); const rootStore = useRootStore();
@@ -275,6 +279,14 @@ export const useUIStore = defineStore(STORES.UI, () => {
return null; return null;
}); });
const lastInteractedWithNode = computed(() => {
if (lastInteractedWithNodeId.value) {
return workflowsStore.getNodeById(lastInteractedWithNodeId.value);
}
return null;
});
const isVersionsOpen = computed(() => { const isVersionsOpen = computed(() => {
return modalsById.value[VERSIONS_MODAL_KEY].open; return modalsById.value[VERSIONS_MODAL_KEY].open;
}); });
@@ -600,6 +612,12 @@ export const useUIStore = defineStore(STORES.UI, () => {
delete pendingNotificationsForViews.value[view]; delete pendingNotificationsForViews.value[view];
}; };
function resetLastInteractedWith() {
lastInteractedWithNodeConnection.value = null;
lastInteractedWithNodeHandle.value = null;
lastInteractedWithNodeId.value = null;
}
return { return {
appliedTheme, appliedTheme,
logo, logo,
@@ -621,7 +639,10 @@ export const useUIStore = defineStore(STORES.UI, () => {
selectedNodes, selectedNodes,
bannersHeight, bannersHeight,
lastSelectedNodeEndpointUuid, lastSelectedNodeEndpointUuid,
lastSelectedNodeConnection, lastInteractedWithNodeConnection,
lastInteractedWithNodeHandle,
lastInteractedWithNodeId,
lastInteractedWithNode,
nodeViewOffsetPosition, nodeViewOffsetPosition,
nodeViewMoveInProgress, nodeViewMoveInProgress,
nodeViewInitialized, nodeViewInitialized,
@@ -670,6 +691,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
clearBannerStack, clearBannerStack,
setNotificationsForView, setNotificationsForView,
deleteNotificationsForView, deleteNotificationsForView,
resetLastInteractedWith,
}; };
}); });

View File

@@ -73,6 +73,7 @@ import { i18n } from '@/plugins/i18n';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import type { ProjectSharingData } from '@/types/projects.types';
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = { const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
name: '', name: '',
@@ -452,6 +453,15 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
return workflowData; return workflowData;
} }
function makeNewWorkflowShareable() {
const { currentProject, personalProject } = useProjectsStore();
const homeProject = currentProject ?? personalProject ?? {};
const scopes = currentProject?.scopes ?? personalProject?.scopes ?? [];
workflow.value.homeProject = homeProject as ProjectSharingData;
workflow.value.scopes = scopes;
}
function resetWorkflow() { function resetWorkflow() {
workflow.value = createEmptyWorkflow(); workflow.value = createEmptyWorkflow();
} }
@@ -1589,6 +1599,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
fetchAllWorkflows, fetchAllWorkflows,
fetchWorkflow, fetchWorkflow,
getNewWorkflowData, getNewWorkflowData,
makeNewWorkflowShareable,
resetWorkflow, resetWorkflow,
resetState, resetState,
addExecutingNode, addExecutingNode,

View File

@@ -2,13 +2,14 @@
import type { import type {
ConnectionTypes, ConnectionTypes,
ExecutionStatus, ExecutionStatus,
IConnection,
INodeConnections, INodeConnections,
INodeTypeDescription, INodeTypeDescription,
} from 'n8n-workflow'; } from 'n8n-workflow';
import type { BrowserJsPlumbInstance } from '@jsplumb/browser-ui';
import type { DefaultEdge, Node, NodeProps, Position } from '@vue-flow/core'; import type { DefaultEdge, Node, NodeProps, Position } from '@vue-flow/core';
import type { INodeUi } from '@/Interface'; import type { INodeUi } from '@/Interface';
import type { ComputedRef, Ref } from 'vue'; import type { ComputedRef, Ref } from 'vue';
import type { PartialBy } from '@/utils/typeHelpers';
export type CanvasConnectionPortType = ConnectionTypes; export type CanvasConnectionPortType = ConnectionTypes;
@@ -107,13 +108,14 @@ export interface CanvasConnectionData {
export type CanvasConnection = DefaultEdge<CanvasConnectionData>; export type CanvasConnection = DefaultEdge<CanvasConnectionData>;
export interface CanvasPluginContext { export type CanvasConnectionCreateData = {
instance: BrowserJsPlumbInstance; source: string;
} target: string;
data: {
export interface CanvasPlugin { source: PartialBy<IConnection, 'node'>;
(ctx: CanvasPluginContext): void; target: PartialBy<IConnection, 'node'>;
} };
};
export interface CanvasNodeInjectionData { export interface CanvasNodeInjectionData {
id: Ref<string>; id: Ref<string>;

View File

@@ -6,8 +6,6 @@ import type { Connection } from '@vue-flow/core';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { isValidCanvasConnectionMode, isValidNodeConnectionType } from '@/utils/typeGuards'; import { isValidCanvasConnectionMode, isValidNodeConnectionType } from '@/utils/typeGuards';
import { NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow';
import type { Connection as VueFlowConnection } from '@vue-flow/core/dist/types/connection';
import { PUSH_NODES_OFFSET } from '@/utils/nodeViewUtils';
export function mapLegacyConnectionsToCanvasConnections( export function mapLegacyConnectionsToCanvasConnections(
legacyConnections: IConnections, legacyConnections: IConnections,
@@ -94,21 +92,6 @@ export function parseCanvasConnectionHandleString(handle: string | null | undefi
}; };
} }
/**
* Get the width and height of a connection
*
* @TODO See whether this is actually needed or just a legacy jsPlumb check
*/
export function getVueFlowConnectorLengths(connection: VueFlowConnection): [number, number] {
const connectionId = createCanvasConnectionId(connection);
const edgeRef = document.getElementById(connectionId);
if (!edgeRef) {
return [PUSH_NODES_OFFSET, PUSH_NODES_OFFSET];
}
return [edgeRef.clientWidth, edgeRef.clientHeight];
}
export function createCanvasConnectionHandleString({ export function createCanvasConnectionHandleString({
mode, mode,
type = NodeConnectionType.Main, type = NodeConnectionType.Main,

View File

@@ -28,10 +28,9 @@ import type {
XYPosition, XYPosition,
} from '@/Interface'; } from '@/Interface';
import type { Connection } from '@vue-flow/core'; import type { Connection } from '@vue-flow/core';
import type { CanvasNode, ConnectStartEvent } from '@/types'; import type { CanvasConnectionCreateData, CanvasNode, ConnectStartEvent } from '@/types';
import { CanvasNodeRenderType } from '@/types'; import { CanvasNodeRenderType } from '@/types';
import { import {
CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT,
CHAT_TRIGGER_NODE_TYPE, CHAT_TRIGGER_NODE_TYPE,
EnterpriseEditionFeature, EnterpriseEditionFeature,
MAIN_HEADER_TABS, MAIN_HEADER_TABS,
@@ -46,13 +45,8 @@ import {
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store'; import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { useExternalHooks } from '@/composables/useExternalHooks'; import { useExternalHooks } from '@/composables/useExternalHooks';
import { TelemetryHelpers } from 'n8n-workflow'; import { TelemetryHelpers, NodeConnectionType } from 'n8n-workflow';
import type { import type { IDataObject, ExecutionSummary, IConnection, IWorkflowBase } from 'n8n-workflow';
NodeConnectionType,
ExecutionSummary,
IConnection,
IWorkflowBase,
} from 'n8n-workflow';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { useCredentialsStore } from '@/stores/credentials.store'; import { useCredentialsStore } from '@/stores/credentials.store';
@@ -70,11 +64,8 @@ import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { useHistoryStore } from '@/stores/history.store'; import { useHistoryStore } from '@/stores/history.store';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import { usePostHog } from '@/stores/posthog.store';
import useWorkflowsEEStore from '@/stores/workflows.ee.store';
import { useNodeHelpers } from '@/composables/useNodeHelpers'; import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useExecutionDebugging } from '@/composables/useExecutionDebugging'; import { useExecutionDebugging } from '@/composables/useExecutionDebugging';
import type { ProjectSharingData } from '@/types/projects.types';
import { useUsersStore } from '@/stores/users.store'; import { useUsersStore } from '@/stores/users.store';
import { sourceControlEventBus } from '@/event-bus/source-control'; import { sourceControlEventBus } from '@/event-bus/source-control';
import { useTagsStore } from '@/stores/tags.store'; import { useTagsStore } from '@/stores/tags.store';
@@ -85,6 +76,7 @@ import CanvasStopCurrentExecutionButton from '@/components/canvas/elements/butto
import CanvasStopWaitingForWebhookButton from '@/components/canvas/elements/buttons/CanvasStopWaitingForWebhookButton.vue'; import CanvasStopWaitingForWebhookButton from '@/components/canvas/elements/buttons/CanvasStopWaitingForWebhookButton.vue';
import CanvasClearExecutionDataButton from '@/components/canvas/elements/buttons/CanvasClearExecutionDataButton.vue'; import CanvasClearExecutionDataButton from '@/components/canvas/elements/buttons/CanvasClearExecutionDataButton.vue';
import { nodeViewEventBus } from '@/event-bus'; import { nodeViewEventBus } from '@/event-bus';
import * as NodeViewUtils from '@/utils/nodeViewUtils';
import { tryToParseNumber } from '@/utils/typesUtils'; import { tryToParseNumber } from '@/utils/typesUtils';
import { useTemplatesStore } from '@/stores/templates.store'; import { useTemplatesStore } from '@/stores/templates.store';
import { createEventBus } from 'n8n-design-system'; import { createEventBus } from 'n8n-design-system';
@@ -105,15 +97,13 @@ const telemetry = useTelemetry();
const externalHooks = useExternalHooks(); const externalHooks = useExternalHooks();
const toast = useToast(); const toast = useToast();
const message = useMessage(); const message = useMessage();
const titleChange = useTitleChange(); const { titleReset, titleSet } = useTitleChange();
const workflowHelpers = useWorkflowHelpers({ router }); const workflowHelpers = useWorkflowHelpers({ router });
const nodeHelpers = useNodeHelpers(); const nodeHelpers = useNodeHelpers();
const posthog = usePostHog();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore(); const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const workflowsEEStore = useWorkflowsEEStore();
const sourceControlStore = useSourceControlStore(); const sourceControlStore = useSourceControlStore();
const nodeCreatorStore = useNodeCreatorStore(); const nodeCreatorStore = useNodeCreatorStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
@@ -153,6 +143,10 @@ const {
revertDeleteConnection, revertDeleteConnection,
setNodeActiveByName, setNodeActiveByName,
addConnections, addConnections,
importWorkflowData,
fetchWorkflowDataFromUrl,
resetWorkspace,
initializeWorkspace,
editableWorkflow, editableWorkflow,
editableWorkflowObject, editableWorkflowObject,
} = useCanvasOperations({ router, lastClickPosition }); } = useCanvasOperations({ router, lastClickPosition });
@@ -202,12 +196,6 @@ const fallbackNodes = computed<INodeUi[]>(() =>
*/ */
async function initializeData() { async function initializeData() {
isLoading.value = true;
canvasStore.startLoading();
resetWorkspace();
titleChange.titleReset();
const loadPromises = (() => { const loadPromises = (() => {
if (settingsStore.isPreviewMode && isDemoRoute.value) return []; if (settingsStore.isPreviewMode && isDemoRoute.value) return [];
@@ -232,9 +220,6 @@ async function initializeData() {
return promises; return promises;
})(); })();
// @TODO Implement this
// this.clipboard.onPaste.value = this.onClipboardPasteEvent;
try { try {
await Promise.all(loadPromises); await Promise.all(loadPromises);
} catch (error) { } catch (error) {
@@ -244,23 +229,10 @@ async function initializeData() {
i18n.baseText('nodeView.showError.mounted1.message') + ':', i18n.baseText('nodeView.showError.mounted1.message') + ':',
); );
return; return;
} finally {
canvasStore.stopLoading();
isLoading.value = false;
} }
setTimeout(() => {
void usersStore.showPersonalizationSurvey();
}, 0);
// @TODO: This currently breaks since front-end hooks are still not updated to work with pinia store
void externalHooks.run('nodeView.mount').catch(() => {});
// @TODO maybe we can find a better way to handle this
canvasStore.isDemo = isDemoRoute.value;
} }
async function initializeView() { async function initializeRoute() {
// In case the workflow got saved we do not have to run init // In case the workflow got saved we do not have to run init
// as only the route changed but all the needed data is already loaded // as only the route changed but all the needed data is already loaded
if (route.params.action === 'workflowSave') { if (route.params.action === 'workflowSave') {
@@ -283,27 +255,12 @@ async function initializeView() {
// If there is no workflow id, treat it as a new workflow // If there is no workflow id, treat it as a new workflow
if (!workflowId.value || isNewWorkflowRoute.value) { if (!workflowId.value || isNewWorkflowRoute.value) {
if (route.meta?.nodeView === true) { if (route.meta?.nodeView === true) {
await initializeViewForNewWorkflow(); await initializeWorkspaceForNewWorkflow();
} }
return; return;
} }
// Load workflow data await initializeWorkspaceForExistingWorkflow(workflowId.value);
try {
await workflowsStore.fetchWorkflow(workflowId.value);
titleChange.titleSet(workflow.value.name, 'IDLE');
await openWorkflow(workflow.value);
await checkAndInitDebugMode();
trackOpenWorkflowFromOnboardingTemplate();
} catch (error) {
toast.showError(error, i18n.baseText('openWorkflow.workflowNotFoundError'));
void router.push({
name: VIEWS.NEW_WORKFLOW,
});
}
} }
nodeHelpers.updateNodesInputIssues(); nodeHelpers.updateNodesInputIssues();
@@ -311,67 +268,40 @@ async function initializeView() {
nodeHelpers.updateNodesParameterIssues(); nodeHelpers.updateNodesParameterIssues();
await loadCredentials(); await loadCredentials();
canvasEventBus.emit('fitView');
uiStore.nodeViewInitialized = true;
// Once view is initialized, pick up all toast notifications
// waiting in the store and display them
toast.showNotificationForViews([VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW]);
} }
async function initializeViewForNewWorkflow() { async function initializeWorkspaceForNewWorkflow() {
resetWorkspace(); resetWorkspace();
await workflowsStore.getNewWorkflowData(undefined, projectsStore.currentProjectId); await workflowsStore.getNewWorkflowData(undefined, projectsStore.currentProjectId);
workflowsStore.makeNewWorkflowShareable();
workflowsStore.currentWorkflowExecutions = [];
executionsStore.activeExecution = null;
uiStore.stateIsDirty = false;
uiStore.nodeViewInitialized = true; uiStore.nodeViewInitialized = true;
executionsStore.activeExecution = null;
makeNewWorkflowShareable();
await runAutoAddManualTriggerExperiment();
} }
/** async function initializeWorkspaceForExistingWorkflow(id: string) {
* Pre-populate the canvas with the manual trigger node resetWorkspace();
* if the experiment is enabled and the user is in the variant group
*/ try {
async function runAutoAddManualTriggerExperiment() { const workflowData = await workflowsStore.fetchWorkflow(id);
if (
posthog.getVariant(CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT.name) !== await openWorkflow(workflowData);
CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT.variant await initializeDebugMode();
) {
return; if (workflowData.meta?.onboardingId) {
trackOpenWorkflowFromOnboardingTemplate();
}
await projectsStore.setProjectNavActiveIdByWorkflowHomeProject(workflow.value.homeProject);
} catch (error) {
toast.showError(error, i18n.baseText('openWorkflow.workflowNotFoundError'));
void router.push({
name: VIEWS.NEW_WORKFLOW,
});
} finally {
uiStore.nodeViewInitialized = true;
} }
const manualTriggerNode = canvasStore.getAutoAddManualTriggerNode();
if (manualTriggerNode) {
await addNodes([manualTriggerNode]);
uiStore.lastSelectedNode = manualTriggerNode.name;
}
}
function resetWorkspace() {
onOpenNodeCreator({ createNodeActive: false });
nodeCreatorStore.setShowScrim(false);
// Make sure that if there is a waiting test-webhook that it gets removed
if (isExecutionWaitingForWebhook.value) {
try {
void workflowsStore.removeTestWebhook(workflowsStore.workflowId);
} catch (error) {}
}
workflowsStore.resetWorkflow();
workflowsStore.resetState();
uiStore.removeActiveAction('workflowRunning');
uiStore.resetSelectedNodes();
uiStore.nodeViewOffsetPosition = [0, 0]; // @TODO Not sure if needed
// this.credentialsUpdated = false;
} }
/** /**
@@ -379,65 +309,38 @@ function resetWorkspace() {
*/ */
async function openWorkflow(data: IWorkflowDb) { async function openWorkflow(data: IWorkflowDb) {
const selectedExecution = executionsStore.activeExecution;
resetWorkspace(); resetWorkspace();
titleSet(workflow.value.name, 'IDLE');
await workflowHelpers.initState(data); await initializeWorkspace(data);
await addNodes(data.nodes);
workflowsStore.setConnections(data.connections);
if (data.sharedWithProjects) {
workflowsEEStore.setWorkflowSharedWith({
workflowId: data.id,
sharedWithProjects: data.sharedWithProjects,
});
}
if (data.usedCredentials) {
workflowsStore.setUsedCredentials(data.usedCredentials);
}
if (!nodeHelpers.credentialsUpdated.value) {
uiStore.stateIsDirty = false;
}
void externalHooks.run('workflow.open', { void externalHooks.run('workflow.open', {
workflowId: data.id, workflowId: data.id,
workflowName: data.name, workflowName: data.name,
}); });
if (selectedExecution?.workflowId !== data.id) { // @TODO Check why this is needed when working on executions
executionsStore.activeExecution = null; // const selectedExecution = executionsStore.activeExecution;
workflowsStore.currentWorkflowExecutions = []; // if (selectedExecution?.workflowId !== data.id) {
} else { // executionsStore.activeExecution = null;
executionsStore.activeExecution = selectedExecution; // workflowsStore.currentWorkflowExecutions = [];
} // } else {
// executionsStore.activeExecution = selectedExecution;
// }
await projectsStore.setProjectNavActiveIdByWorkflowHomeProject(workflow.value.homeProject); fitView();
} }
function trackOpenWorkflowFromOnboardingTemplate() { function trackOpenWorkflowFromOnboardingTemplate() {
if (workflow.value.meta?.onboardingId) { telemetry.track(
telemetry.track( `User opened workflow from onboarding template with ID ${workflow.value.meta?.onboardingId}`,
`User opened workflow from onboarding template with ID ${workflow.value.meta.onboardingId}`, {
{ workflow_id: workflowId.value,
workflow_id: workflowId.value, },
}, {
{ withPostHog: true,
withPostHog: true, },
}, );
);
}
}
function makeNewWorkflowShareable() {
const { currentProject, personalProject } = projectsStore;
const homeProject = currentProject ?? personalProject ?? {};
const scopes = currentProject?.scopes ?? personalProject?.scopes ?? [];
workflowsStore.workflow.homeProject = homeProject as ProjectSharingData;
workflowsStore.workflow.scopes = scopes;
} }
/** /**
@@ -485,7 +388,6 @@ async function openWorkflowTemplate(templateId: string) {
uiStore.stateIsDirty = true; uiStore.stateIsDirty = true;
canvasEventBus.emit('fitView');
canvasStore.stopLoading(); canvasStore.stopLoading();
void externalHooks.run('template.open', { void externalHooks.run('template.open', {
@@ -493,6 +395,8 @@ async function openWorkflowTemplate(templateId: string) {
templateName: data.name, templateName: data.name,
workflow: data.workflow, workflow: data.workflow,
}); });
fitView();
} }
function trackOpenWorkflowTemplate(templateId: string) { function trackOpenWorkflowTemplate(templateId: string) {
@@ -631,8 +535,46 @@ function onRevertDeleteConnection({ connection }: { connection: [IConnection, IC
* Import / Export * Import / Export
*/ */
async function importWorkflowExact(_workflow: IWorkflowDataUpdate) { async function importWorkflowExact({ workflow: workflowData }: { workflow: IWorkflowDataUpdate }) {
// @TODO if (!workflowData.nodes || !workflowData.connections) {
throw new Error('Invalid workflow object');
}
resetWorkspace();
await initializeWorkspace({
...workflowData,
nodes: NodeViewUtils.getFixedNodesList<INodeUi>(workflowData.nodes),
} as IWorkflowDb);
fitView();
}
async function onImportWorkflowDataEvent(data: IDataObject) {
await importWorkflowData(data.data as IWorkflowDataUpdate, 'file');
fitView();
}
async function onImportWorkflowUrlEvent(data: IDataObject) {
const workflowData = await fetchWorkflowDataFromUrl(data.url as string);
if (!workflowData) {
return;
}
await importWorkflowData(workflowData, 'url');
fitView();
}
function addImportEventBindings() {
nodeViewEventBus.on('importWorkflowData', onImportWorkflowDataEvent);
nodeViewEventBus.on('importWorkflowUrl', onImportWorkflowUrlEvent);
}
function removeImportEventBindings() {
nodeViewEventBus.off('importWorkflowData', onImportWorkflowDataEvent);
nodeViewEventBus.off('importWorkflowUrl', onImportWorkflowUrlEvent);
} }
/** /**
@@ -649,11 +591,31 @@ async function onAddNodesAndConnections(
} }
await addNodes(nodes, { dragAndDrop, position }); await addNodes(nodes, { dragAndDrop, position });
await addConnections(connections, {
offsetIndex: editableWorkflow.value.nodes.length - nodes.length, const offsetIndex = editableWorkflow.value.nodes.length - nodes.length;
const mappedConnections: CanvasConnectionCreateData[] = connections.map(({ from, to }) => {
const fromNode = editableWorkflow.value.nodes[offsetIndex + from.nodeIndex];
const toNode = editableWorkflow.value.nodes[offsetIndex + to.nodeIndex];
return {
source: fromNode.id,
target: toNode.id,
data: {
source: {
index: from.outputIndex ?? 0,
type: NodeConnectionType.Main,
},
target: {
index: to.inputIndex ?? 0,
type: NodeConnectionType.Main,
},
},
};
}); });
uiStore.lastSelectedNodeConnection = null; await addConnections(mappedConnections);
uiStore.resetLastInteractedWith();
} }
async function onSwitchActiveNode(nodeName: string) { async function onSwitchActiveNode(nodeName: string) {
@@ -865,7 +827,7 @@ async function onSourceControlPull() {
if (workflowId.value !== null && !uiStore.stateIsDirty) { if (workflowId.value !== null && !uiStore.stateIsDirty) {
const workflowData = await workflowsStore.fetchWorkflow(workflowId.value); const workflowData = await workflowsStore.fetchWorkflow(workflowId.value);
if (workflowData) { if (workflowData) {
titleChange.titleSet(workflowData.name, 'IDLE'); titleSet(workflowData.name, 'IDLE');
await openWorkflow(workflowData); await openWorkflow(workflowData);
} }
} }
@@ -909,7 +871,7 @@ async function onPostMessageReceived(message: MessageEvent) {
const json = JSON.parse(message.data); const json = JSON.parse(message.data);
if (json && json.command === 'openWorkflow') { if (json && json.command === 'openWorkflow') {
try { try {
await importWorkflowExact(json.data); await importWorkflowExact(json);
canOpenNDV.value = json.canOpenNDV ?? true; canOpenNDV.value = json.canOpenNDV ?? true;
hideNodeIssues.value = json.hideNodeIssues ?? false; hideNodeIssues.value = json.hideNodeIssues ?? false;
isExecutionPreview.value = false; isExecutionPreview.value = false;
@@ -1009,9 +971,9 @@ function checkIfRouteIsAllowed() {
* Debug mode * Debug mode
*/ */
async function checkAndInitDebugMode() { async function initializeDebugMode() {
if (route.name === VIEWS.EXECUTION_DEBUG) { if (route.name === VIEWS.EXECUTION_DEBUG) {
titleChange.titleSet(workflowsStore.workflowName, 'DEBUG'); titleSet(workflowsStore.workflowName, 'DEBUG');
if (!workflowsStore.isInDebugMode) { if (!workflowsStore.isInDebugMode) {
await applyExecutionData(route.params.executionId as string); await applyExecutionData(route.params.executionId as string);
workflowsStore.isInDebugMode = true; workflowsStore.isInDebugMode = true;
@@ -1019,6 +981,14 @@ async function checkAndInitDebugMode() {
} }
} }
/**
* Canvas
*/
function fitView() {
setTimeout(() => canvasEventBus.emit('fitView'));
}
/** /**
* Mouse events * Mouse events
*/ */
@@ -1130,8 +1100,23 @@ onBeforeMount(() => {
}); });
onMounted(async () => { onMounted(async () => {
canvasStore.startLoading();
titleReset();
resetWorkspace();
void initializeData().then(() => { void initializeData().then(() => {
void initializeView(); void initializeRoute()
.then(() => {
// Once view is initialized, pick up all toast notifications
// waiting in the store and display them
toast.showNotificationForViews([VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW]);
})
.finally(() => {
isLoading.value = false;
canvasStore.stopLoading();
});
void usersStore.showPersonalizationSurvey();
checkIfRouteIsAllowed(); checkIfRouteIsAllowed();
}); });
@@ -1140,21 +1125,29 @@ onMounted(async () => {
addPostMessageEventBindings(); addPostMessageEventBindings();
addKeyboardEventBindings(); addKeyboardEventBindings();
addSourceControlEventBindings(); addSourceControlEventBindings();
addImportEventBindings();
registerCustomActions(); registerCustomActions();
// @TODO Implement this
// this.clipboard.onPaste.value = this.onClipboardPasteEvent;
// @TODO: This currently breaks since front-end hooks are still not updated to work with pinia store
void externalHooks.run('nodeView.mount').catch(() => {});
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
removeKeyboardEventBindings();
removePostMessageEventBindings();
removeUndoRedoEventBindings(); removeUndoRedoEventBindings();
removePostMessageEventBindings();
removeKeyboardEventBindings();
removeSourceControlEventBindings(); removeSourceControlEventBindings();
removeImportEventBindings();
}); });
</script> </script>
<template> <template>
<WorkflowCanvas <WorkflowCanvas
v-if="editableWorkflow && editableWorkflowObject" v-if="editableWorkflow && editableWorkflowObject && !isLoading"
:workflow="editableWorkflow" :workflow="editableWorkflow"
:workflow-object="editableWorkflowObject" :workflow-object="editableWorkflowObject"
:fallback-nodes="fallbackNodes" :fallback-nodes="fallbackNodes"