diff --git a/cypress/composables/modals/save-changes-modal.ts b/cypress/composables/modals/save-changes-modal.ts new file mode 100644 index 0000000000..d44b09bd46 --- /dev/null +++ b/cypress/composables/modals/save-changes-modal.ts @@ -0,0 +1,3 @@ +export function getSaveChangesModal() { + return cy.get('.el-overlay').contains('Save changes before leaving?'); +} diff --git a/cypress/e2e/44-routing.cy.ts b/cypress/e2e/44-routing.cy.ts new file mode 100644 index 0000000000..67a092235b --- /dev/null +++ b/cypress/e2e/44-routing.cy.ts @@ -0,0 +1,26 @@ +import { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows'; +import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; +import { EDIT_FIELDS_SET_NODE_NAME } from '../constants'; +import { getSaveChangesModal } from '../composables/modals/save-changes-modal'; + +const WorkflowsPage = new WorkflowsPageClass(); +const WorkflowPage = new WorkflowPageClass(); + +describe('Workflows', () => { + beforeEach(() => { + cy.visit(WorkflowsPage.url); + }); + + it('should ask to save unsaved changes before leaving route', () => { + WorkflowsPage.getters.newWorkflowButtonCard().should('be.visible'); + WorkflowsPage.getters.newWorkflowButtonCard().click(); + + cy.createFixtureWorkflow('Test_workflow_1.json', 'Empty State Card Workflow'); + + WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME); + + cy.getByTestId('project-home-menu-item').click(); + + getSaveChangesModal().should('be.visible'); + }); +}); diff --git a/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsList.vue b/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsList.vue index 316e1887c5..22dd7c00e0 100644 --- a/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsList.vue +++ b/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsList.vue @@ -61,6 +61,7 @@ export default defineComponent({ next(); return; } + if (this.uiStore.stateIsDirty) { const confirmModal = await this.confirm( this.$locale.baseText('generic.unsavedWork.confirmMessage.message'), diff --git a/packages/editor-ui/src/composables/__tests__/useCanvasOperations.spec.ts b/packages/editor-ui/src/composables/__tests__/useCanvasOperations.spec.ts index ffdbe8be52..7e6c9a0921 100644 --- a/packages/editor-ui/src/composables/__tests__/useCanvasOperations.spec.ts +++ b/packages/editor-ui/src/composables/__tests__/useCanvasOperations.spec.ts @@ -4,13 +4,12 @@ import type { IConnection, Workflow } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow'; import { useCanvasOperations } from '@/composables/useCanvasOperations'; import type { CanvasNode } from '@/types'; -import type { ICredentialsResponse, INodeUi, IWorkflowDb, XYPosition } from '@/Interface'; +import type { ICredentialsResponse, INodeUi, IWorkflowDb } from '@/Interface'; import { RemoveNodeCommand } from '@/models/history'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { useUIStore } from '@/stores/ui.store'; import { useHistoryStore } from '@/stores/history.store'; import { useNDVStore } from '@/stores/ndv.store'; -import { ref } from 'vue'; import { createTestNode, createTestWorkflowObject, @@ -49,7 +48,6 @@ describe('useCanvasOperations', () => { let canvasOperations: ReturnType; let workflowHelpers: ReturnType; - const lastClickPosition = ref([450, 450]); const router = useRouter(); beforeEach(async () => { @@ -77,7 +75,7 @@ describe('useCanvasOperations', () => { workflowsStore.resetState(); workflowHelpers.initState(workflow); - canvasOperations = useCanvasOperations({ router, lastClickPosition }); + canvasOperations = useCanvasOperations({ router }); vi.clearAllMocks(); }); @@ -135,7 +133,7 @@ describe('useCanvasOperations', () => { mockNodeTypeDescription({ name: 'type' }), ); - expect(result.position).toEqual([460, 460]); // Default last click position + expect(result.position).toEqual([0, 0]); // Default last click position }); it('should create node with provided position when position is provided', () => { diff --git a/packages/editor-ui/src/composables/useCanvasOperations.ts b/packages/editor-ui/src/composables/useCanvasOperations.ts index 2edc175963..2dd8a1f404 100644 --- a/packages/editor-ui/src/composables/useCanvasOperations.ts +++ b/packages/editor-ui/src/composables/useCanvasOperations.ts @@ -87,8 +87,7 @@ import type { } from 'n8n-workflow'; import { deepCopy, NodeConnectionType, NodeHelpers, TelemetryHelpers } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; -import type { Ref } from 'vue'; -import { computed, nextTick } from 'vue'; +import { computed, nextTick, ref } from 'vue'; import type { useRouter } from 'vue-router'; import { useClipboard } from '@/composables/useClipboard'; import { isPresent } from '../utils/typesUtils'; @@ -108,13 +107,7 @@ type AddNodeOptions = { isAutoAdd?: boolean; }; -export function useCanvasOperations({ - router, - lastClickPosition, -}: { - router: ReturnType; - lastClickPosition: Ref; -}) { +export function useCanvasOperations({ router }: { router: ReturnType }) { const rootStore = useRootStore(); const workflowsStore = useWorkflowsStore(); const credentialsStore = useCredentialsStore(); @@ -136,6 +129,8 @@ export function useCanvasOperations({ const externalHooks = useExternalHooks(); const clipboard = useClipboard(); + const lastClickPosition = ref([0, 0]); + const preventOpeningNDV = !!localStorage.getItem('NodeView.preventOpeningNDV'); const editableWorkflow = computed(() => workflowsStore.workflow); @@ -1698,6 +1693,7 @@ export function useCanvasOperations({ } return { + lastClickPosition, editableWorkflow, editableWorkflowObject, triggerNodes, diff --git a/packages/editor-ui/src/composables/useWorkflowHelpers.ts b/packages/editor-ui/src/composables/useWorkflowHelpers.ts index 7a317110c1..178b480ae5 100644 --- a/packages/editor-ui/src/composables/useWorkflowHelpers.ts +++ b/packages/editor-ui/src/composables/useWorkflowHelpers.ts @@ -1,5 +1,6 @@ import { HTTP_REQUEST_NODE_TYPE, + MODAL_CANCEL, MODAL_CONFIRM, PLACEHOLDER_EMPTY_WORKFLOW_ID, PLACEHOLDER_FILLED_AT_EXECUTION_TIME, @@ -66,6 +67,8 @@ 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 type { NavigationGuardNext } from 'vue-router'; type ResolveParameterOptions = { targetItem?: TargetItem; @@ -1055,6 +1058,52 @@ 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(); + if (goToNext) { + next(); + } + } else if (confirmModal === MODAL_CANCEL) { + await cancel(); + + uiStore.stateIsDirty = false; + next(); + } + } else { + next(); + } + } + function initState(workflowData: IWorkflowDb) { workflowsStore.addWorkflow(workflowData); workflowsStore.setActive(workflowData.active || false); @@ -1107,6 +1156,7 @@ export function useWorkflowHelpers(options: { router: ReturnType([0, 0]); - const { runWorkflow, stopCurrentExecution, stopWaitingForWebhook } = useRunWorkflow({ router }); const { updateNodePosition, @@ -171,7 +167,8 @@ const { initializeWorkspace, editableWorkflow, editableWorkflowObject, -} = useCanvasOperations({ router, lastClickPosition }); + lastClickPosition, +} = useCanvasOperations({ router }); const { applyExecutionData } = useExecutionDebugging(); useClipboard({ onPaste: onClipboardPaste }); @@ -1372,66 +1369,6 @@ function registerCustomActions() { // }); } -/** - * Routing - */ - -onBeforeRouteLeave(async (to, from, next) => { - const toNodeViewTab = getNodeViewTab(to); - - if ( - toNodeViewTab === MAIN_HEADER_TABS.EXECUTIONS || - from.name === VIEWS.TEMPLATE_IMPORT || - (toNodeViewTab === MAIN_HEADER_TABS.WORKFLOW && from.name === VIEWS.EXECUTION_DEBUG) - ) { - next(); - return; - } - - 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) { - // Make sure workflow id is empty when leaving the editor - workflowsStore.setWorkflowId(PLACEHOLDER_EMPTY_WORKFLOW_ID); - const saved = await workflowHelpers.saveCurrentWorkflow({}, false); - if (saved) { - await npsSurveyStore.fetchPromptsData(); - } - uiStore.stateIsDirty = false; - - 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. - await router.replace({ - name: VIEWS.WORKFLOW, - params: { name: workflowId.value }, - }); - - await router.push(to); - } else { - next(); - } - } else if (confirmModal === MODAL_CANCEL) { - workflowsStore.setWorkflowId(PLACEHOLDER_EMPTY_WORKFLOW_ID); - resetWorkspace(); - uiStore.stateIsDirty = false; - next(); - } - } else { - next(); - } -}); - /** * Lifecycle */ diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index fbb32faaad..91ebdf1228 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -414,61 +414,6 @@ export default defineComponent({ ContextMenu, LazySetupWorkflowCredentialsButton, }, - async beforeRouteLeave(to, from, next) { - if ( - getNodeViewTab(to) === MAIN_HEADER_TABS.EXECUTIONS || - from.name === VIEWS.TEMPLATE_IMPORT || - (getNodeViewTab(to) === MAIN_HEADER_TABS.WORKFLOW && from.name === VIEWS.EXECUTION_DEBUG) - ) { - next(); - return; - } - if (this.uiStore.stateIsDirty && !this.readOnlyEnv) { - const confirmModal = await this.confirm( - this.$locale.baseText('generic.unsavedWork.confirmMessage.message'), - { - title: this.$locale.baseText('generic.unsavedWork.confirmMessage.headline'), - type: 'warning', - confirmButtonText: this.$locale.baseText( - 'generic.unsavedWork.confirmMessage.confirmButtonText', - ), - cancelButtonText: this.$locale.baseText( - 'generic.unsavedWork.confirmMessage.cancelButtonText', - ), - showClose: true, - }, - ); - if (confirmModal === MODAL_CONFIRM) { - // Make sure workflow id is empty when leaving the editor - this.workflowsStore.setWorkflowId(PLACEHOLDER_EMPTY_WORKFLOW_ID); - const saved = await this.workflowHelpers.saveCurrentWorkflow({}, false); - if (saved) { - await this.npsSurveyStore.fetchPromptsData(); - } - this.uiStore.stateIsDirty = false; - - 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. - await this.$router.replace({ - name: VIEWS.WORKFLOW, - params: { name: this.currentWorkflow }, - }); - - await this.$router.push(to); - } else { - next(); - } - } else if (confirmModal === MODAL_CANCEL) { - this.workflowsStore.setWorkflowId(PLACEHOLDER_EMPTY_WORKFLOW_ID); - this.resetWorkspace(); - this.uiStore.stateIsDirty = false; - next(); - } - } else { - next(); - } - }, setup() { const nodeViewRootRef = ref(null); const nodeViewRef = ref(null); diff --git a/packages/editor-ui/src/views/NodeViewSwitcher.vue b/packages/editor-ui/src/views/NodeViewSwitcher.vue index 0c6f80166d..94bf62eb6d 100644 --- a/packages/editor-ui/src/views/NodeViewSwitcher.vue +++ b/packages/editor-ui/src/views/NodeViewSwitcher.vue @@ -1,17 +1,80 @@