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

@@ -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"