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);