fix(editor): Close saving modal when workflow is new (#14836)

This commit is contained in:
Mutasem Aldmour
2025-04-29 15:48:25 +02:00
committed by GitHub
parent a33e3a807a
commit 48f0c91a47
5 changed files with 312 additions and 67 deletions

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import WorkflowExecutionsSidebar from '@/components/executions/workflow/WorkflowExecutionsSidebar.vue'; import WorkflowExecutionsSidebar from '@/components/executions/workflow/WorkflowExecutionsSidebar.vue';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
import { MAIN_HEADER_TABS } from '@/constants'; import { MAIN_HEADER_TABS } from '@/constants';
import type { ExecutionFilterType, IWorkflowDb } from '@/Interface'; import type { ExecutionFilterType, IWorkflowDb } from '@/Interface';
import { getNodeViewTab } from '@/utils/nodeViewUtils'; import { getNodeViewTab } from '@/utils/nodeViewUtils';
@@ -33,7 +33,8 @@ const emit = defineEmits<{
reload: []; reload: [];
}>(); }>();
const workflowHelpers = useWorkflowHelpers({ router: useRouter() }); const router = useRouter();
const { promptSaveUnsavedWorkflowChanges } = useWorkflowSaving({ router });
const temporaryExecution = computed<ExecutionSummary | undefined>(() => const temporaryExecution = computed<ExecutionSummary | undefined>(() =>
props.executions.find((execution) => execution.id === props.execution?.id) props.executions.find((execution) => execution.id === props.execution?.id)
@@ -72,7 +73,7 @@ onBeforeRouteLeave(async (to, _, next) => {
return; return;
} }
await workflowHelpers.promptSaveUnsavedWorkflowChanges(next); await promptSaveUnsavedWorkflowChanges(next);
}); });
</script> </script>

View File

@@ -1,7 +1,5 @@
import { import {
HTTP_REQUEST_NODE_TYPE, HTTP_REQUEST_NODE_TYPE,
MODAL_CANCEL,
MODAL_CLOSE,
MODAL_CONFIRM, MODAL_CONFIRM,
PLACEHOLDER_EMPTY_WORKFLOW_ID, PLACEHOLDER_EMPTY_WORKFLOW_ID,
PLACEHOLDER_FILLED_AT_EXECUTION_TIME, PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
@@ -70,12 +68,11 @@ import { useCanvasStore } from '@/stores/canvas.store';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { tryToParseNumber } from '@/utils/typesUtils'; import { tryToParseNumber } from '@/utils/typesUtils';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import type { useRouter, NavigationGuardNext } from 'vue-router'; import type { useRouter } from 'vue-router';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import { useTagsStore } from '@/stores/tags.store'; import { useTagsStore } from '@/stores/tags.store';
import { useWorkflowsEEStore } from '@/stores/workflows.ee.store'; import { useWorkflowsEEStore } from '@/stores/workflows.ee.store';
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
import { findWebhook } from '../api/webhooks'; import { findWebhook } from '../api/webhooks';
export type ResolveParameterOptions = { export type ResolveParameterOptions = {
@@ -1161,63 +1158,6 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
} }
} }
async function promptSaveUnsavedWorkflowChanges(
next: NavigationGuardNext,
{
confirm = async () => true,
cancel = async () => {},
}: {
confirm?: () => Promise<boolean>;
cancel?: () => Promise<void>;
} = {},
) {
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) { function initState(workflowData: IWorkflowDb) {
workflowsStore.addWorkflow(workflowData); workflowsStore.addWorkflow(workflowData);
workflowsStore.setActive(workflowData.active || false); workflowsStore.setActive(workflowData.active || false);
@@ -1310,7 +1250,6 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
updateNodePositions, updateNodePositions,
removeForeignCredentialsFromWorkflow, removeForeignCredentialsFromWorkflow,
getWorkflowProjectRole, getWorkflowProjectRole,
promptSaveUnsavedWorkflowChanges,
initState, initState,
getNodeParametersWithResolvedExpressions, getNodeParametersWithResolvedExpressions,
containsNodeFromPackage, containsNodeFromPackage,

View File

@@ -0,0 +1,207 @@
import { useUIStore } from '@/stores/ui.store';
import { MODAL_CANCEL, MODAL_CONFIRM, PLACEHOLDER_EMPTY_WORKFLOW_ID, VIEWS } from '@/constants';
import { useWorkflowSaving } from './useWorkflowSaving';
import router from '@/router';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
const modalConfirmSpy = vi.fn();
const saveCurrentWorkflowSpy = vi.fn();
vi.mock('@/composables/useMessage', () => {
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 },
}),
);
});
});

View File

@@ -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<typeof useRouter> }) {
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<boolean>;
cancel?: () => Promise<void>;
} = {},
) {
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,
};
}

View File

@@ -114,6 +114,7 @@ import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
import type { CanvasLayoutEvent } from '@/composables/useCanvasLayout'; import type { CanvasLayoutEvent } from '@/composables/useCanvasLayout';
import { useClearExecutionButtonVisible } from '@/composables/useClearExecutionButtonVisible'; import { useClearExecutionButtonVisible } from '@/composables/useClearExecutionButtonVisible';
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs'; import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
import { useBuilderStore } from '@/stores/builder.store'; import { useBuilderStore } from '@/stores/builder.store';
import { useFoldersStore } from '@/stores/folders.store'; import { useFoldersStore } from '@/stores/folders.store';
@@ -1744,14 +1745,15 @@ onBeforeRouteLeave(async (to, from, next) => {
return; return;
} }
await workflowHelpers.promptSaveUnsavedWorkflowChanges(next, { await useWorkflowSaving({ router }).promptSaveUnsavedWorkflowChanges(next, {
async confirm() { async confirm() {
if (from.name === VIEWS.NEW_WORKFLOW) { if (from.name === VIEWS.NEW_WORKFLOW) {
// Replace the current route with the new workflow route // Replace the current route with the new workflow route
// before navigating to the new route when saving new workflow. // before navigating to the new route when saving new workflow.
const savedWorkflowId = workflowsStore.workflowId;
await router.replace({ await router.replace({
name: VIEWS.WORKFLOW, name: VIEWS.WORKFLOW,
params: { name: workflowId.value }, params: { name: savedWorkflowId },
}); });
await router.push(to); await router.push(to);