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

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

View File

@@ -1,5 +1,14 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref, useCssModule } from 'vue';
import {
computed,
defineAsyncComponent,
nextTick,
onBeforeMount,
onBeforeUnmount,
onMounted,
ref,
useCssModule,
} from 'vue';
import { useRoute, useRouter } from 'vue-router';
import WorkflowCanvas from '@/components/canvas/WorkflowCanvas.vue';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
@@ -13,6 +22,7 @@ import type {
INodeUi,
IUpdateInformation,
IWorkflowDataUpdate,
IWorkflowDb,
ToggleNodeCreatorOptions,
XYPosition,
} from '@/Interface';
@@ -21,15 +31,19 @@ import type { CanvasElement } from '@/types';
import {
CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT,
EnterpriseEditionFeature,
MODAL_CANCEL,
MODAL_CONFIRM,
NEW_WORKFLOW_ID,
VIEWS,
} from '@/constants';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { useExternalHooks } from '@/composables/useExternalHooks';
import type { NodeConnectionType, ExecutionSummary, IConnection } from 'n8n-workflow';
import { TelemetryHelpers } from 'n8n-workflow';
import type {
NodeConnectionType,
ExecutionSummary,
IConnection,
IWorkflowBase,
} from 'n8n-workflow';
import { useToast } from '@/composables/useToast';
import { useSettingsStore } from '@/stores/settings.store';
import { useCredentialsStore } from '@/stores/credentials.store';
@@ -48,6 +62,14 @@ 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';
import { usePushConnectionStore } from '@/stores/pushConnection.store';
const NodeCreation = defineAsyncComponent(
async () => await import('@/components/Node/NodeCreation.vue'),
@@ -67,10 +89,13 @@ const toast = useToast();
const message = useMessage();
const titleChange = 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();
@@ -83,6 +108,9 @@ const canvasStore = useCanvasStore();
const npsSurveyStore = useNpsSurveyStore();
const historyStore = useHistoryStore();
const projectsStore = useProjectsStore();
const usersStore = useUsersStore();
const tagsStore = useTagsStore();
const pushConnectionStore = usePushConnectionStore();
const lastClickPosition = ref<XYPosition>([450, 450]);
@@ -105,6 +133,7 @@ const {
editableWorkflow,
editableWorkflowObject,
} = useCanvasOperations({ router, lastClickPosition });
const { applyExecutionData } = useExecutionDebugging();
const isLoading = ref(true);
const isBlankRedirect = ref(false);
@@ -120,6 +149,7 @@ const hideNodeIssues = ref(false);
const workflowId = computed<string>(() => route.params.workflowId as string);
const workflow = computed(() => workflowsStore.workflowsById[workflowId.value]);
const isNewWorkflowRoute = computed(() => route.name === VIEWS.NEW_WORKFLOW_V2);
const isDemoRoute = computed(() => route.name === VIEWS.DEMO);
const isReadOnlyRoute = computed(() => route?.meta?.readOnlyCanvas === true);
const isReadOnlyEnvironment = computed(() => {
@@ -132,58 +162,61 @@ const isReadOnlyEnvironment = computed(() => {
async function initializeData() {
isLoading.value = true;
canvasStore.startLoading();
resetWorkspace();
titleChange.titleReset();
const loadPromises: Array<Promise<unknown>> = [
nodeTypesStore.getNodeTypes(),
workflowsStore.fetchWorkflow(workflowId.value),
];
const loadPromises = (() => {
if (settingsStore.isPreviewMode && isDemoRoute.value) return [];
if (!settingsStore.isPreviewMode && !isDemoRoute.value) {
loadPromises.push(
const promises: Array<Promise<unknown>> = [
workflowsStore.fetchActiveWorkflows(),
credentialsStore.fetchAllCredentials(),
credentialsStore.fetchCredentialTypes(true),
);
];
if (settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Variables)) {
loadPromises.push(environmentsStore.fetchAllVariables());
promises.push(environmentsStore.fetchAllVariables());
}
if (settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.ExternalSecrets)) {
loadPromises.push(externalSecretsStore.fetchAllSecrets());
promises.push(externalSecretsStore.fetchAllSecrets());
}
}
try {
await Promise.all(loadPromises);
} catch (error) {
return toast.showError(
error,
i18n.baseText('nodeView.showError.mounted1.title'),
i18n.baseText('nodeView.showError.mounted1.message') + ':',
);
}
if (nodeTypesStore.allNodeTypes.length === 0) {
promises.push(nodeTypesStore.getNodeTypes());
}
void externalHooks.run('workflow.open', {
workflowId: workflowsStore.workflow.id,
workflowName: workflowsStore.workflow.name,
});
const selectedExecution = executionsStore.activeExecution;
if (selectedExecution?.workflowId !== workflowsStore.workflow.id) {
executionsStore.activeExecution = null;
workflowsStore.currentWorkflowExecutions = [];
} else {
executionsStore.activeExecution = selectedExecution;
}
return promises;
})();
// @TODO Implement this
// this.clipboard.onPaste.value = this.onClipboardPasteEvent;
isLoading.value = false;
try {
await Promise.all(loadPromises);
} catch (error) {
toast.showError(
error,
i18n.baseText('nodeView.showError.mounted1.title'),
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() {
@@ -205,28 +238,6 @@ async function initializeView() {
// const templateId = route.params.id;
// await openWorkflowTemplate(templateId.toString());
} else {
if (uiStore.stateIsDirty && !isReadOnlyEnvironment.value) {
const confirmModal = await message.confirm(
i18n.baseText('generic.unsavedWork.confirmMessage.message'),
{
title: i18n.baseText('generic.unsavedWork.confirmMessage.headline'),
type: 'warning',
confirmButtonText: i18n.baseText('generic.unsavedWork.confirmMessage.confirmButtonText'),
cancelButtonText: i18n.baseText('generic.unsavedWork.confirmMessage.cancelButtonText'),
showClose: true,
},
);
if (confirmModal === MODAL_CONFIRM) {
const saved = await workflowHelpers.saveCurrentWorkflow();
if (saved) {
await npsSurveyStore.fetchPromptsData();
}
} else if (confirmModal === MODAL_CANCEL) {
return;
}
}
// Get workflow id
let workflowIdParam: string | null = null;
if (route.params.workflowId) {
@@ -236,7 +247,7 @@ async function initializeView() {
historyStore.reset();
// If there is no workflow id, treat it as a new workflow
if (!workflowIdParam || workflowIdParam === NEW_WORKFLOW_ID) {
if (!workflowIdParam || isNewWorkflowRoute.value) {
if (route.meta?.nodeView === true) {
await initializeViewForNewWorkflow();
}
@@ -248,24 +259,25 @@ async function initializeView() {
await workflowsStore.fetchWorkflow(workflowIdParam);
titleChange.titleSet(workflow.value.name, 'IDLE');
// @TODO Implement this
// await openWorkflow(workflow);
// await checkAndInitDebugMode();
workflowsStore.initializeEditableWorkflow(workflowIdParam);
await projectsStore.setProjectNavActiveIdByWorkflowHomeProject(workflow.value.homeProject);
await openWorkflow(workflow.value);
await checkAndInitDebugMode();
trackOpenWorkflowFromOnboardingTemplate();
} catch (error) {
toast.showError(error, i18n.baseText('openWorkflow.workflowNotFoundError'));
void router.push({
name: VIEWS.NEW_WORKFLOW,
name: VIEWS.NEW_WORKFLOW_V2,
});
}
}
nodeHelpers.updateNodesInputIssues();
nodeHelpers.updateNodesCredentialsIssues();
nodeHelpers.updateNodesParameterIssues();
await loadCredentials();
uiStore.nodeViewInitialized = true;
// Once view is initialized, pick up all toast notifications
@@ -284,53 +296,113 @@ async function initializeViewForNewWorkflow() {
uiStore.nodeViewInitialized = true;
executionsStore.activeExecution = null;
// @TODO Implement this
// canvasStore.setZoomLevel(1, [0, 0]);
// canvasStore.zoomToFit();
makeNewWorkflowShareable();
await runAutoAddManualTriggerExperiment();
}
// @TODO Implement this
// this.makeNewWorkflowShareable();
// Pre-populate the canvas with the manual trigger node if the experiment is enabled and the user is in the variant group
const { getVariant } = usePostHog();
/**
* 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 (
getVariant(CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT.name) ===
posthog.getVariant(CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT.name) !==
CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT.variant
) {
const manualTriggerNode = canvasStore.getAutoAddManualTriggerNode();
if (manualTriggerNode) {
await addNodes([manualTriggerNode]);
uiStore.lastSelectedNode = manualTriggerNode.name;
return;
}
const manualTriggerNode = canvasStore.getAutoAddManualTriggerNode();
if (manualTriggerNode) {
await addNodes([manualTriggerNode]);
uiStore.lastSelectedNode = manualTriggerNode.name;
}
}
// @ts-expect-error @TODO Add binding on route leave
async function promptSaveOnBeforeRouteLeave() {
if (uiStore.stateIsDirty && !isReadOnlyEnvironment.value) {
const confirmModal = await message.confirm(
i18n.baseText('generic.unsavedWork.confirmMessage.message'),
{
title: i18n.baseText('generic.unsavedWork.confirmMessage.headline'),
type: 'warning',
confirmButtonText: i18n.baseText('generic.unsavedWork.confirmMessage.confirmButtonText'),
cancelButtonText: i18n.baseText('generic.unsavedWork.confirmMessage.cancelButtonText'),
showClose: true,
},
);
if (confirmModal === MODAL_CONFIRM) {
const saved = await workflowHelpers.saveCurrentWorkflow();
if (saved) {
await npsSurveyStore.fetchPromptsData();
}
}
}
}
function resetWorkspace() {
workflowsStore.resetWorkflow();
onToggleNodeCreator({ createNodeActive: false });
nodeCreatorStore.setShowScrim(false);
// @TODO Implement this
// Reset nodes
// this.unbindEndpointEventListeners();
// this.deleteEveryEndpoint();
// 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.removeActiveAction('workflowRunning');
uiStore.resetSelectedNodes();
uiStore.nodeViewOffsetPosition = [0, 0]; // @TODO Not sure if needed
// this.credentialsUpdated = false;
}
/**
* Workflow
*/
async function openWorkflow(data: IWorkflowDb) {
const selectedExecution = executionsStore.activeExecution;
resetWorkspace();
await workflowHelpers.initState(data, true);
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', {
workflowId: data.id,
workflowName: data.name,
});
if (selectedExecution?.workflowId !== data.id) {
executionsStore.activeExecution = null;
workflowsStore.currentWorkflowExecutions = [];
} else {
executionsStore.activeExecution = selectedExecution;
}
await projectsStore.setProjectNavActiveIdByWorkflowHomeProject(workflow.value.homeProject);
}
function trackOpenWorkflowFromOnboardingTemplate() {
if (workflow.value.meta?.onboardingId) {
telemetry.track(
@@ -345,6 +417,15 @@ function trackOpenWorkflowFromOnboardingTemplate() {
}
}
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;
}
/**
* Nodes
*/
@@ -479,27 +560,32 @@ function onToggleNodeCreator(options: ToggleNodeCreatorOptions) {
*/
async function onRunWorkflow() {
trackRunWorkflow();
await runWorkflow({});
}
function trackRunWorkflow() {
void workflowHelpers.getWorkflowDataToSave().then((workflowData) => {
const telemetryPayload = {
workflow_id: workflowId.value,
node_graph_string: JSON.stringify(
TelemetryHelpers.generateNodesGraph(
workflowData as IWorkflowBase,
workflowHelpers.getNodeTypes(),
{ isCloudDeployment: settingsStore.isCloudDeployment },
).nodeGraph,
),
};
telemetry.track('User clicked execute workflow button', telemetryPayload);
void externalHooks.run('nodeView.onRunWorkflow', telemetryPayload);
});
}
async function openExecution(_executionId: string) {
// @TODO
}
/**
* Unload
*/
function addUnloadEventBindings() {
// window.addEventListener('beforeunload', this.onBeforeUnload);
// window.addEventListener('unload', this.onUnload);
}
function removeUnloadEventBindings() {
// window.removeEventListener('beforeunload', this.onBeforeUnload);
// window.removeEventListener('unload', this.onUnload);
}
/**
* Keboard
*/
@@ -538,6 +624,38 @@ function removeUndoRedoEventBindings() {
// historyBus.off('enableNodeToggle', onRevertEnableToggle);
}
/**
* Source control
*/
async function onSourceControlPull() {
try {
await Promise.all([
environmentsStore.fetchAllVariables(),
tagsStore.fetchAll(),
loadCredentials(),
]);
if (workflowId.value !== null && !uiStore.stateIsDirty) {
const workflowData = await workflowsStore.fetchWorkflow(workflowId.value);
if (workflowData) {
titleChange.titleSet(workflowData.name, 'IDLE');
await openWorkflow(workflowData);
}
}
} catch (error) {
console.error(error);
}
}
function addSourceControlEventBindings() {
sourceControlEventBus.on('pull', onSourceControlPull);
}
function removeSourceControlEventBindings() {
sourceControlEventBus.off('pull', onSourceControlPull);
}
/**
* Post message events
*/
@@ -647,6 +765,34 @@ function checkIfEditingIsAllowed(): boolean {
return true;
}
function checkIfRouteIsAllowed() {
if (
isReadOnlyEnvironment.value &&
[VIEWS.NEW_WORKFLOW, VIEWS.TEMPLATE_IMPORT].find((view) => view === route.name)
) {
void nextTick(async () => {
resetWorkspace();
uiStore.stateIsDirty = false;
await router.replace({ name: VIEWS.HOMEPAGE });
});
}
}
/**
* Debug mode
*/
async function checkAndInitDebugMode() {
if (route.name === VIEWS.EXECUTION_DEBUG) {
titleChange.titleSet(workflowsStore.workflowName, 'DEBUG');
if (!workflowsStore.isInDebugMode) {
await applyExecutionData(route.params.executionId as string);
workflowsStore.isInDebugMode = true;
}
}
}
/**
* Mouse events
*/
@@ -656,25 +802,66 @@ function onClickPane(position: CanvasElement['position']) {
canvasStore.newNodeInsertPosition = [position.x, position.y];
}
/**
* Custom Actions
*/
function registerCustomActions() {
// @TODO Implement these
// this.registerCustomAction({
// key: 'openNodeDetail',
// action: ({ node }: { node: string }) => {
// this.nodeSelectedByName(node, true);
// },
// });
//
// this.registerCustomAction({
// key: 'openSelectiveNodeCreator',
// action: this.openSelectiveNodeCreator,
// });
//
// this.registerCustomAction({
// key: 'showNodeCreator',
// action: () => {
// this.ndvStore.activeNodeName = null;
//
// void this.$nextTick(() => {
// this.showTriggerCreator(NODE_CREATOR_OPEN_SOURCES.TAB);
// });
// },
// });
}
/**
* Lifecycle
*/
onBeforeMount(() => {
if (!isDemoRoute.value) {
pushConnectionStore.pushConnect();
}
});
onMounted(async () => {
await initializeData();
await initializeView();
void initializeData().then(() => {
void initializeView();
checkIfRouteIsAllowed();
});
addUndoRedoEventBindings();
addPostMessageEventBindings();
addKeyboardEventBindings();
addUnloadEventBindings();
addSourceControlEventBindings();
registerCustomActions();
});
onBeforeUnmount(() => {
removeUnloadEventBindings();
removeKeyboardEventBindings();
removePostMessageEventBindings();
removeUndoRedoEventBindings();
removeSourceControlEventBindings();
});
</script>