diff --git a/packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionsList.vue b/packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionsList.vue
index 48ece142dd..032e19724c 100644
--- a/packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionsList.vue
+++ b/packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionsList.vue
@@ -1,6 +1,6 @@
diff --git a/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.ts b/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.ts
index 6e592d4c07..460458fd40 100644
--- a/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.ts
+++ b/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.ts
@@ -1,7 +1,5 @@
import {
HTTP_REQUEST_NODE_TYPE,
- MODAL_CANCEL,
- MODAL_CLOSE,
MODAL_CONFIRM,
PLACEHOLDER_EMPTY_WORKFLOW_ID,
PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
@@ -70,12 +68,11 @@ import { useCanvasStore } from '@/stores/canvas.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { tryToParseNumber } from '@/utils/typesUtils';
import { useI18n } from '@/composables/useI18n';
-import type { useRouter, NavigationGuardNext } from 'vue-router';
+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';
-import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
import { findWebhook } from '../api/webhooks';
export type ResolveParameterOptions = {
@@ -1161,63 +1158,6 @@ export function useWorkflowHelpers(options: { router: ReturnType true,
- cancel = async () => {},
- }: {
- confirm?: () => Promise;
- cancel?: () => Promise;
- } = {},
- ) {
- if (uiStore.stateIsDirty) {
- const npsSurveyStore = useNpsSurveyStore();
-
- 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 saveCurrentWorkflow({}, false);
- if (saved) {
- await npsSurveyStore.fetchPromptsData();
- uiStore.stateIsDirty = false;
- const goToNext = await confirm();
- next(goToNext);
- } else {
- next(
- router.resolve({
- name: VIEWS.WORKFLOW,
- params: { name: workflowsStore.workflow.id },
- }),
- );
- }
- } else if (confirmModal === MODAL_CANCEL) {
- await cancel();
-
- uiStore.stateIsDirty = false;
- next();
- } else if (confirmModal === MODAL_CLOSE) {
- // The route may have already changed due to the browser back button, so let's restore it
- next(
- router.resolve({
- name: VIEWS.WORKFLOW,
- params: { name: workflowsStore.workflow.id },
- }),
- );
- }
- } else {
- next();
- }
- }
-
function initState(workflowData: IWorkflowDb) {
workflowsStore.addWorkflow(workflowData);
workflowsStore.setActive(workflowData.active || false);
@@ -1310,7 +1250,6 @@ export function useWorkflowHelpers(options: { router: ReturnType {
+ return {
+ useMessage: () => ({
+ confirm: modalConfirmSpy,
+ }),
+ };
+});
+
+vi.mock('@/composables/useWorkflowHelpers', () => {
+ return {
+ useWorkflowHelpers: () => ({
+ saveCurrentWorkflow: saveCurrentWorkflowSpy,
+ }),
+ };
+});
+
+describe('promptSaveUnsavedWorkflowChanges', () => {
+ beforeAll(() => {
+ setActivePinia(createTestingPinia());
+ });
+
+ beforeEach(() => {
+ vi.resetAllMocks();
+ });
+
+ it('should prompt the user to save changes and proceed if confirmed', async () => {
+ const { promptSaveUnsavedWorkflowChanges } = useWorkflowSaving({ router });
+ const next = vi.fn();
+ const confirm = vi.fn().mockResolvedValue(true);
+ const cancel = vi.fn();
+
+ // Mock state
+ const uiStore = useUIStore();
+ uiStore.stateIsDirty = true;
+
+ const npsSurveyStore = useNpsSurveyStore();
+ vi.spyOn(npsSurveyStore, 'fetchPromptsData').mockResolvedValue();
+
+ saveCurrentWorkflowSpy.mockResolvedValue(true);
+
+ // Mock message.confirm
+ modalConfirmSpy.mockResolvedValue(MODAL_CONFIRM);
+
+ await promptSaveUnsavedWorkflowChanges(next, { confirm, cancel });
+
+ expect(modalConfirmSpy).toHaveBeenCalled();
+ expect(npsSurveyStore.fetchPromptsData).toHaveBeenCalled();
+ expect(saveCurrentWorkflowSpy).toHaveBeenCalledWith({}, false);
+ expect(uiStore.stateIsDirty).toEqual(false);
+
+ expect(confirm).toHaveBeenCalled();
+ expect(next).toHaveBeenCalledWith(true);
+ expect(cancel).not.toHaveBeenCalled();
+ });
+
+ it('should not proceed if the user cancels the confirmation modal', async () => {
+ const { promptSaveUnsavedWorkflowChanges } = useWorkflowSaving({ router });
+ const next = vi.fn();
+ const confirm = vi.fn();
+ const cancel = vi.fn();
+
+ // Mock state
+ const uiStore = useUIStore();
+ uiStore.stateIsDirty = true;
+
+ // Mock message.confirm
+ modalConfirmSpy.mockResolvedValue(MODAL_CANCEL);
+
+ await promptSaveUnsavedWorkflowChanges(next, { confirm, cancel });
+
+ expect(modalConfirmSpy).toHaveBeenCalled();
+ expect(saveCurrentWorkflowSpy).not.toHaveBeenCalled();
+ expect(uiStore.stateIsDirty).toEqual(false);
+
+ expect(confirm).not.toHaveBeenCalled();
+ expect(cancel).toHaveBeenCalled();
+ expect(next).toHaveBeenCalledWith();
+ });
+
+ it('should restore the route if the modal is closed and the workflow is not new', async () => {
+ const { promptSaveUnsavedWorkflowChanges } = useWorkflowSaving({ router });
+ const next = vi.fn();
+ const confirm = vi.fn();
+ const cancel = vi.fn();
+
+ // Mock state
+ const uiStore = useUIStore();
+ uiStore.stateIsDirty = true;
+
+ const workflowStore = useWorkflowsStore();
+ const MOCK_ID = 'existing-workflow-id';
+ workflowStore.workflow.id = MOCK_ID;
+
+ // Mock message.confirm
+ modalConfirmSpy.mockResolvedValue('close');
+
+ await promptSaveUnsavedWorkflowChanges(next, { confirm, cancel });
+
+ expect(modalConfirmSpy).toHaveBeenCalled();
+ expect(saveCurrentWorkflowSpy).not.toHaveBeenCalled();
+ expect(uiStore.stateIsDirty).toEqual(true);
+
+ expect(confirm).not.toHaveBeenCalled();
+ expect(cancel).not.toHaveBeenCalled();
+ expect(next).toHaveBeenCalledWith(
+ router.resolve({
+ name: VIEWS.WORKFLOW,
+ params: { name: MOCK_ID },
+ }),
+ );
+ });
+
+ it('should close modal if workflow is not new', async () => {
+ const { promptSaveUnsavedWorkflowChanges } = useWorkflowSaving({ router });
+ const next = vi.fn();
+ const confirm = vi.fn();
+ const cancel = vi.fn();
+
+ // Mock state
+ const uiStore = useUIStore();
+ uiStore.stateIsDirty = true;
+
+ const workflowStore = useWorkflowsStore();
+ workflowStore.workflow.id = PLACEHOLDER_EMPTY_WORKFLOW_ID;
+
+ // Mock message.confirm
+ modalConfirmSpy.mockResolvedValue('close');
+
+ await promptSaveUnsavedWorkflowChanges(next, { confirm, cancel });
+
+ expect(modalConfirmSpy).toHaveBeenCalled();
+ expect(saveCurrentWorkflowSpy).not.toHaveBeenCalled();
+ expect(uiStore.stateIsDirty).toEqual(true);
+
+ expect(confirm).not.toHaveBeenCalled();
+ expect(cancel).not.toHaveBeenCalled();
+ expect(next).not.toHaveBeenCalled();
+ });
+
+ it('should proceed without prompting if there are no unsaved changes', async () => {
+ const { promptSaveUnsavedWorkflowChanges } = useWorkflowSaving({ router });
+ const next = vi.fn();
+ const confirm = vi.fn();
+ const cancel = vi.fn();
+
+ // Mock state
+ const uiStore = useUIStore();
+ uiStore.stateIsDirty = false;
+
+ await promptSaveUnsavedWorkflowChanges(next, { confirm, cancel });
+
+ expect(modalConfirmSpy).not.toHaveBeenCalled();
+ expect(saveCurrentWorkflowSpy).not.toHaveBeenCalled();
+ expect(uiStore.stateIsDirty).toEqual(false);
+
+ expect(confirm).not.toHaveBeenCalled();
+ expect(cancel).not.toHaveBeenCalled();
+ expect(next).toHaveBeenCalledWith();
+ });
+
+ it('should handle save failure and restore the route', async () => {
+ const { promptSaveUnsavedWorkflowChanges } = useWorkflowSaving({ router });
+ const next = vi.fn();
+ const confirm = vi.fn();
+ const cancel = vi.fn();
+
+ // Mock state
+ const uiStore = useUIStore();
+ uiStore.stateIsDirty = true;
+
+ const workflowStore = useWorkflowsStore();
+ const MOCK_ID = 'existing-workflow-id';
+ workflowStore.workflow.id = MOCK_ID;
+
+ saveCurrentWorkflowSpy.mockResolvedValue(false);
+
+ // Mock message.confirm
+ modalConfirmSpy.mockResolvedValue(MODAL_CONFIRM);
+
+ await promptSaveUnsavedWorkflowChanges(next, { confirm, cancel });
+
+ expect(modalConfirmSpy).toHaveBeenCalled();
+ expect(saveCurrentWorkflowSpy).toHaveBeenCalledWith({}, false);
+ expect(uiStore.stateIsDirty).toEqual(true);
+
+ expect(confirm).not.toHaveBeenCalled();
+ expect(cancel).not.toHaveBeenCalled();
+ expect(next).toHaveBeenCalledWith(
+ router.resolve({
+ name: VIEWS.WORKFLOW,
+ params: { name: MOCK_ID },
+ }),
+ );
+ });
+});
diff --git a/packages/frontend/editor-ui/src/composables/useWorkflowSaving.ts b/packages/frontend/editor-ui/src/composables/useWorkflowSaving.ts
new file mode 100644
index 0000000000..3a69636ec2
--- /dev/null
+++ b/packages/frontend/editor-ui/src/composables/useWorkflowSaving.ts
@@ -0,0 +1,96 @@
+import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
+import { useUIStore } from '@/stores/ui.store';
+import type { NavigationGuardNext, useRouter } from 'vue-router';
+import { useMessage } from './useMessage';
+import { useI18n } from '@/composables/useI18n';
+import {
+ MODAL_CANCEL,
+ MODAL_CLOSE,
+ MODAL_CONFIRM,
+ PLACEHOLDER_EMPTY_WORKFLOW_ID,
+ VIEWS,
+} from '@/constants';
+import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
+import { useWorkflowsStore } from '@/stores/workflows.store';
+
+export function useWorkflowSaving({ router }: { router: ReturnType }) {
+ const uiStore = useUIStore();
+ const npsSurveyStore = useNpsSurveyStore();
+ const message = useMessage();
+ const i18n = useI18n();
+ const workflowsStore = useWorkflowsStore();
+
+ const { saveCurrentWorkflow } = useWorkflowHelpers({ router });
+
+ async function promptSaveUnsavedWorkflowChanges(
+ next: NavigationGuardNext,
+ {
+ confirm = async () => true,
+ cancel = async () => {},
+ }: {
+ confirm?: () => Promise;
+ cancel?: () => Promise;
+ } = {},
+ ) {
+ if (!uiStore.stateIsDirty) {
+ next();
+
+ return;
+ }
+
+ const response = 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,
+ },
+ );
+
+ switch (response) {
+ case MODAL_CONFIRM:
+ const saved = await saveCurrentWorkflow({}, false);
+ if (saved) {
+ await npsSurveyStore.fetchPromptsData();
+ uiStore.stateIsDirty = false;
+ const goToNext = await confirm();
+ next(goToNext);
+ } else {
+ // if new workflow and did not save, modal reopens again to force user to try to save again
+ stayOnCurrentWorkflow(next);
+ }
+
+ return;
+ case MODAL_CANCEL:
+ await cancel();
+
+ uiStore.stateIsDirty = false;
+ next();
+
+ return;
+ case MODAL_CLOSE:
+ // for new workflows that are not saved yet, don't do anything, only close modal
+ if (workflowsStore.workflow.id !== PLACEHOLDER_EMPTY_WORKFLOW_ID) {
+ stayOnCurrentWorkflow(next);
+ }
+
+ return;
+ }
+ }
+
+ function stayOnCurrentWorkflow(next: NavigationGuardNext) {
+ // The route may have already changed due to the browser back button, so let's restore it
+ next(
+ router.resolve({
+ name: VIEWS.WORKFLOW,
+ params: { name: workflowsStore.workflow.id },
+ }),
+ );
+ }
+
+ return {
+ promptSaveUnsavedWorkflowChanges,
+ };
+}
diff --git a/packages/frontend/editor-ui/src/views/NodeView.vue b/packages/frontend/editor-ui/src/views/NodeView.vue
index af7bd15883..91c39f9e22 100644
--- a/packages/frontend/editor-ui/src/views/NodeView.vue
+++ b/packages/frontend/editor-ui/src/views/NodeView.vue
@@ -114,6 +114,7 @@ import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
import type { CanvasLayoutEvent } from '@/composables/useCanvasLayout';
import { useClearExecutionButtonVisible } from '@/composables/useClearExecutionButtonVisible';
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
+import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
import { useBuilderStore } from '@/stores/builder.store';
import { useFoldersStore } from '@/stores/folders.store';
@@ -1744,14 +1745,15 @@ onBeforeRouteLeave(async (to, from, next) => {
return;
}
- await workflowHelpers.promptSaveUnsavedWorkflowChanges(next, {
+ await useWorkflowSaving({ router }).promptSaveUnsavedWorkflowChanges(next, {
async confirm() {
if (from.name === VIEWS.NEW_WORKFLOW) {
// Replace the current route with the new workflow route
// before navigating to the new route when saving new workflow.
+ const savedWorkflowId = workflowsStore.workflowId;
await router.replace({
name: VIEWS.WORKFLOW,
- params: { name: workflowId.value },
+ params: { name: savedWorkflowId },
});
await router.push(to);