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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1296 additions and 810 deletions

View file

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

View file

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

View file

@ -61,15 +61,114 @@ describe('useCanvasOperations', () => {
const workflow = mock<IWorkflowDb>({
id: workflowId,
nodes: [],
connections: {},
tags: [],
usedCredentials: [],
});
workflowsStore.workflowsById[workflowId] = workflow;
workflowsStore.resetWorkflow();
workflowsStore.resetState();
await workflowHelpers.initState(workflow);
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', () => {
it('should update node position', () => {
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', () => {
it('should add nodes at specified positions', async () => {
const nodeTypeName = 'type';
@ -489,6 +490,7 @@ describe('useCanvasOperations', () => {
const nodes = [
mockNode({ id: 'a', name: 'Node A', 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([
@ -504,14 +506,27 @@ describe('useCanvasOperations', () => {
.mockReturnValueOnce(nodes[1]);
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');
await canvasOperations.addConnections(connections, { offsetIndex });
await canvasOperations.addConnections(connections);
expect(addConnectionSpy).toHaveBeenCalledWith({
connection: [

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,8 +6,6 @@ import type { Connection } from '@vue-flow/core';
import { v4 as uuid } from 'uuid';
import { isValidCanvasConnectionMode, isValidNodeConnectionType } from '@/utils/typeGuards';
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(
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({
mode,
type = NodeConnectionType.Main,

View file

@ -28,10 +28,9 @@ import type {
XYPosition,
} from '@/Interface';
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 {
CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT,
CHAT_TRIGGER_NODE_TYPE,
EnterpriseEditionFeature,
MAIN_HEADER_TABS,
@ -46,13 +45,8 @@ import {
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { TelemetryHelpers } from 'n8n-workflow';
import type {
NodeConnectionType,
ExecutionSummary,
IConnection,
IWorkflowBase,
} from 'n8n-workflow';
import { TelemetryHelpers, NodeConnectionType } from 'n8n-workflow';
import type { IDataObject, ExecutionSummary, IConnection, IWorkflowBase } from 'n8n-workflow';
import { useToast } from '@/composables/useToast';
import { useSettingsStore } from '@/stores/settings.store';
import { useCredentialsStore } from '@/stores/credentials.store';
@ -70,11 +64,8 @@ import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useTelemetry } from '@/composables/useTelemetry';
import { useHistoryStore } from '@/stores/history.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 { useExecutionDebugging } from '@/composables/useExecutionDebugging';
import type { ProjectSharingData } from '@/types/projects.types';
import { useUsersStore } from '@/stores/users.store';
import { sourceControlEventBus } from '@/event-bus/source-control';
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 CanvasClearExecutionDataButton from '@/components/canvas/elements/buttons/CanvasClearExecutionDataButton.vue';
import { nodeViewEventBus } from '@/event-bus';
import * as NodeViewUtils from '@/utils/nodeViewUtils';
import { tryToParseNumber } from '@/utils/typesUtils';
import { useTemplatesStore } from '@/stores/templates.store';
import { createEventBus } from 'n8n-design-system';
@ -105,15 +97,13 @@ const telemetry = useTelemetry();
const externalHooks = useExternalHooks();
const toast = useToast();
const message = useMessage();
const titleChange = useTitleChange();
const { titleReset, titleSet } = useTitleChange();
const workflowHelpers = useWorkflowHelpers({ router });
const nodeHelpers = useNodeHelpers();
const posthog = usePostHog();
const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
const workflowsEEStore = useWorkflowsEEStore();
const sourceControlStore = useSourceControlStore();
const nodeCreatorStore = useNodeCreatorStore();
const settingsStore = useSettingsStore();
@ -153,6 +143,10 @@ const {
revertDeleteConnection,
setNodeActiveByName,
addConnections,
importWorkflowData,
fetchWorkflowDataFromUrl,
resetWorkspace,
initializeWorkspace,
editableWorkflow,
editableWorkflowObject,
} = useCanvasOperations({ router, lastClickPosition });
@ -202,12 +196,6 @@ const fallbackNodes = computed<INodeUi[]>(() =>
*/
async function initializeData() {
isLoading.value = true;
canvasStore.startLoading();
resetWorkspace();
titleChange.titleReset();
const loadPromises = (() => {
if (settingsStore.isPreviewMode && isDemoRoute.value) return [];
@ -232,9 +220,6 @@ async function initializeData() {
return promises;
})();
// @TODO Implement this
// this.clipboard.onPaste.value = this.onClipboardPasteEvent;
try {
await Promise.all(loadPromises);
} catch (error) {
@ -244,23 +229,10 @@ async function initializeData() {
i18n.baseText('nodeView.showError.mounted1.message') + ':',
);
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
// as only the route changed but all the needed data is already loaded
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 (!workflowId.value || isNewWorkflowRoute.value) {
if (route.meta?.nodeView === true) {
await initializeViewForNewWorkflow();
await initializeWorkspaceForNewWorkflow();
}
return;
}
// Load workflow data
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,
});
}
await initializeWorkspaceForExistingWorkflow(workflowId.value);
}
nodeHelpers.updateNodesInputIssues();
@ -311,67 +268,40 @@ async function initializeView() {
nodeHelpers.updateNodesParameterIssues();
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();
await workflowsStore.getNewWorkflowData(undefined, projectsStore.currentProjectId);
workflowsStore.makeNewWorkflowShareable();
workflowsStore.currentWorkflowExecutions = [];
executionsStore.activeExecution = null;
uiStore.stateIsDirty = false;
uiStore.nodeViewInitialized = true;
executionsStore.activeExecution = null;
makeNewWorkflowShareable();
await runAutoAddManualTriggerExperiment();
}
/**
* Pre-populate the canvas with the manual trigger node
* if the experiment is enabled and the user is in the variant group
*/
async function runAutoAddManualTriggerExperiment() {
if (
posthog.getVariant(CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT.name) !==
CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT.variant
) {
return;
async function initializeWorkspaceForExistingWorkflow(id: string) {
resetWorkspace();
try {
const workflowData = await workflowsStore.fetchWorkflow(id);
await openWorkflow(workflowData);
await initializeDebugMode();
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) {
const selectedExecution = executionsStore.activeExecution;
resetWorkspace();
titleSet(workflow.value.name, 'IDLE');
await workflowHelpers.initState(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;
}
await initializeWorkspace(data);
void externalHooks.run('workflow.open', {
workflowId: data.id,
workflowName: data.name,
});
if (selectedExecution?.workflowId !== data.id) {
executionsStore.activeExecution = null;
workflowsStore.currentWorkflowExecutions = [];
} else {
executionsStore.activeExecution = selectedExecution;
}
// @TODO Check why this is needed when working on executions
// const selectedExecution = executionsStore.activeExecution;
// if (selectedExecution?.workflowId !== data.id) {
// executionsStore.activeExecution = null;
// workflowsStore.currentWorkflowExecutions = [];
// } else {
// executionsStore.activeExecution = selectedExecution;
// }
await projectsStore.setProjectNavActiveIdByWorkflowHomeProject(workflow.value.homeProject);
fitView();
}
function trackOpenWorkflowFromOnboardingTemplate() {
if (workflow.value.meta?.onboardingId) {
telemetry.track(
`User opened workflow from onboarding template with ID ${workflow.value.meta.onboardingId}`,
{
workflow_id: workflowId.value,
},
{
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;
telemetry.track(
`User opened workflow from onboarding template with ID ${workflow.value.meta?.onboardingId}`,
{
workflow_id: workflowId.value,
},
{
withPostHog: true,
},
);
}
/**
@ -485,7 +388,6 @@ async function openWorkflowTemplate(templateId: string) {
uiStore.stateIsDirty = true;
canvasEventBus.emit('fitView');
canvasStore.stopLoading();
void externalHooks.run('template.open', {
@ -493,6 +395,8 @@ async function openWorkflowTemplate(templateId: string) {
templateName: data.name,
workflow: data.workflow,
});
fitView();
}
function trackOpenWorkflowTemplate(templateId: string) {
@ -631,8 +535,46 @@ function onRevertDeleteConnection({ connection }: { connection: [IConnection, IC
* Import / Export
*/
async function importWorkflowExact(_workflow: IWorkflowDataUpdate) {
// @TODO
async function importWorkflowExact({ workflow: workflowData }: { workflow: IWorkflowDataUpdate }) {
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 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) {
@ -865,7 +827,7 @@ async function onSourceControlPull() {
if (workflowId.value !== null && !uiStore.stateIsDirty) {
const workflowData = await workflowsStore.fetchWorkflow(workflowId.value);
if (workflowData) {
titleChange.titleSet(workflowData.name, 'IDLE');
titleSet(workflowData.name, 'IDLE');
await openWorkflow(workflowData);
}
}
@ -909,7 +871,7 @@ async function onPostMessageReceived(message: MessageEvent) {
const json = JSON.parse(message.data);
if (json && json.command === 'openWorkflow') {
try {
await importWorkflowExact(json.data);
await importWorkflowExact(json);
canOpenNDV.value = json.canOpenNDV ?? true;
hideNodeIssues.value = json.hideNodeIssues ?? false;
isExecutionPreview.value = false;
@ -1009,9 +971,9 @@ function checkIfRouteIsAllowed() {
* Debug mode
*/
async function checkAndInitDebugMode() {
async function initializeDebugMode() {
if (route.name === VIEWS.EXECUTION_DEBUG) {
titleChange.titleSet(workflowsStore.workflowName, 'DEBUG');
titleSet(workflowsStore.workflowName, 'DEBUG');
if (!workflowsStore.isInDebugMode) {
await applyExecutionData(route.params.executionId as string);
workflowsStore.isInDebugMode = true;
@ -1019,6 +981,14 @@ async function checkAndInitDebugMode() {
}
}
/**
* Canvas
*/
function fitView() {
setTimeout(() => canvasEventBus.emit('fitView'));
}
/**
* Mouse events
*/
@ -1130,8 +1100,23 @@ onBeforeMount(() => {
});
onMounted(async () => {
canvasStore.startLoading();
titleReset();
resetWorkspace();
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();
});
@ -1140,21 +1125,29 @@ onMounted(async () => {
addPostMessageEventBindings();
addKeyboardEventBindings();
addSourceControlEventBindings();
addImportEventBindings();
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(() => {
removeKeyboardEventBindings();
removePostMessageEventBindings();
removeUndoRedoEventBindings();
removePostMessageEventBindings();
removeKeyboardEventBindings();
removeSourceControlEventBindings();
removeImportEventBindings();
});
</script>
<template>
<WorkflowCanvas
v-if="editableWorkflow && editableWorkflowObject"
v-if="editableWorkflow && editableWorkflowObject && !isLoading"
:workflow="editableWorkflow"
:workflow-object="editableWorkflowObject"
:fallback-nodes="fallbackNodes"