From 558665436c6fff3aced4021ac3f79dd7ea239500 Mon Sep 17 00:00:00 2001 From: Michael Queyrichon Date: Wed, 13 Dec 2023 12:00:59 +0100 Subject: [PATCH] Warn user when leaving bpmn editor with unsaved changes (#11715) * Upgrade react-router-dom from 6.18.0 to 6.20.0 * Refactor routes * Create new hook to block navigation * Implement new hook * Move App code into RouterProvider * Add tests for useConfirmationNavigation hook * Fix unit tests * Fix Canvas unit tests * Rename hook to useConfirmationDialogOnPageLeave * Rename AppShell and Layout * Fix missing type * Add missing changes * Fixes after cr --- frontend/app-development/index.tsx | 16 +- frontend/app-development/{ => layout}/App.css | 0 .../{ => layout}/App.module.css | 2 +- .../app-development/{ => layout}/App.test.tsx | 13 +- frontend/app-development/{ => layout}/App.tsx | 17 +-- ...{AppShell.test.tsx => PageLayout.test.tsx} | 6 +- .../layout/{AppShell.tsx => PageLayout.tsx} | 2 +- frontend/app-development/package.json | 2 +- .../router/PageRoutes.module.css | 6 - .../app-development/router/PageRoutes.tsx | 50 +++--- frontend/app-preview/package.json | 2 +- frontend/dashboard/package.json | 2 +- frontend/language/src/nb.json | 1 + .../process-editor/src/ProcessEditor.test.tsx | 37 +++-- .../src/components/Canvas/Canvas.test.tsx | 39 +++-- .../src/components/Canvas/Canvas.tsx | 10 +- .../src/contexts/BpmnContext.tsx | 2 +- frontend/packages/shared/package.json | 2 +- .../useConfirmationDialogOnPageLeave.test.tsx | 142 ++++++++++++++++++ .../hooks/useConfirmationDialogOnPageLeave.ts | 34 +++++ frontend/resourceadm/package.json | 2 +- package.json | 8 +- yarn.lock | 54 +++---- 23 files changed, 329 insertions(+), 120 deletions(-) rename frontend/app-development/{ => layout}/App.css (100%) rename frontend/app-development/{ => layout}/App.module.css (92%) rename frontend/app-development/{ => layout}/App.test.tsx (83%) rename frontend/app-development/{ => layout}/App.tsx (91%) rename frontend/app-development/layout/{AppShell.test.tsx => PageLayout.test.tsx} (96%) rename frontend/app-development/layout/{AppShell.tsx => PageLayout.tsx} (96%) delete mode 100644 frontend/app-development/router/PageRoutes.module.css create mode 100644 frontend/packages/shared/src/hooks/useConfirmationDialogOnPageLeave.test.tsx create mode 100644 frontend/packages/shared/src/hooks/useConfirmationDialogOnPageLeave.ts diff --git a/frontend/app-development/index.tsx b/frontend/app-development/index.tsx index 181c17d610e..a94a55a441b 100644 --- a/frontend/app-development/index.tsx +++ b/frontend/app-development/index.tsx @@ -3,9 +3,6 @@ import { createRoot } from 'react-dom/client'; import { Provider } from 'react-redux'; import { run } from './sagas'; import { setupStore } from './store'; -import { BrowserRouter } from 'react-router-dom'; -import { App } from './App'; -import { APP_DEVELOPMENT_BASENAME } from 'app-shared/constants'; import { ServicesContextProvider } from 'app-shared/contexts/ServicesContext'; import * as queries from 'app-shared/api/queries'; import * as mutations from 'app-shared/api/mutations'; @@ -15,6 +12,7 @@ import { LoggerConfig, LoggerContextProvider } from 'app-shared/contexts/LoggerC import 'app-shared/design-tokens'; import { altinnStudioEnvironment } from 'app-shared/utils/altinnStudioEnv'; import { QueryClientConfig } from '@tanstack/react-query'; +import { PageRoutes } from './router/PageRoutes'; const store = setupStore(); @@ -44,13 +42,11 @@ const queryClientConfig: QueryClientConfig = { root.render( - - - - - - - + + + + + , ); diff --git a/frontend/app-development/App.css b/frontend/app-development/layout/App.css similarity index 100% rename from frontend/app-development/App.css rename to frontend/app-development/layout/App.css diff --git a/frontend/app-development/App.module.css b/frontend/app-development/layout/App.module.css similarity index 92% rename from frontend/app-development/App.module.css rename to frontend/app-development/layout/App.module.css index 68de738a5a7..77fc309e022 100644 --- a/frontend/app-development/App.module.css +++ b/frontend/app-development/layout/App.module.css @@ -7,7 +7,7 @@ ); --left-menu-width: 68px; - background-color: lightgray; + background: var(--fds-semantic-background-default); min-height: 100vh; width: 100%; display: flex; diff --git a/frontend/app-development/App.test.tsx b/frontend/app-development/layout/App.test.tsx similarity index 83% rename from frontend/app-development/App.test.tsx rename to frontend/app-development/layout/App.test.tsx index 980f0775245..11da8bf7a4f 100644 --- a/frontend/app-development/App.test.tsx +++ b/frontend/app-development/layout/App.test.tsx @@ -1,15 +1,15 @@ import React from 'react'; import { App } from './App'; -import type { IUserState } from './sharedResources/user/userSlice'; +import type { IUserState } from '../sharedResources/user/userSlice'; import { screen } from '@testing-library/react'; import { APP_DEVELOPMENT_BASENAME } from 'app-shared/constants'; -import { renderWithProviders } from './test/testUtils'; -import * as testids from '../testing/testids'; -import { textMock } from '../testing/mocks/i18nMock'; +import { renderWithProviders } from '../test/testUtils'; +import * as testids from '../../testing/testids'; +import { textMock } from '../../testing/mocks/i18nMock'; import { queriesMock } from 'app-shared/mocks/queriesMock'; -jest.mock('../language/src/nb.json', jest.fn()); -jest.mock('../language/src/en.json', jest.fn()); +jest.mock('../../language/src/nb.json', jest.fn()); +jest.mock('../../language/src/en.json', jest.fn()); // Mocking console.error due to Tanstack Query removing custom logger between V4 and v5 see issue: #11692 const realConsole = console; @@ -26,6 +26,7 @@ const render = async (remainingMinutes: number = 40) => { }, }); }; + describe('App', () => { beforeEach(() => { global.console = { diff --git a/frontend/app-development/App.tsx b/frontend/app-development/layout/App.tsx similarity index 91% rename from frontend/app-development/App.tsx rename to frontend/app-development/layout/App.tsx index 791a82acd89..f976ef124ef 100644 --- a/frontend/app-development/App.tsx +++ b/frontend/app-development/layout/App.tsx @@ -1,16 +1,16 @@ import React, { useCallback, useEffect, useRef } from 'react'; import postMessages from 'app-shared/utils/postMessages'; import { AltinnPopoverSimple } from 'app-shared/components/molecules/AltinnPopoverSimple'; -import { HandleServiceInformationActions } from './features/overview/handleServiceInformationSlice'; +import { HandleServiceInformationActions } from '../features/overview/handleServiceInformationSlice'; import { fetchRemainingSession, keepAliveSession, signOutUser, -} from './sharedResources/user/userSlice'; +} from '../sharedResources/user/userSlice'; import './App.css'; -import { matchPath, useLocation } from 'react-router-dom'; +import { Outlet, matchPath, useLocation } from 'react-router-dom'; import classes from './App.module.css'; -import { useAppDispatch, useAppSelector } from './hooks'; +import { useAppDispatch, useAppSelector } from '../hooks'; import { getRepositoryType } from 'app-shared/utils/repository'; import { RepositoryType } from 'app-shared/types/global'; import { @@ -21,12 +21,11 @@ import { } from 'app-shared/api/paths'; import i18next from 'i18next'; import { initReactI18next, useTranslation } from 'react-i18next'; -import nb from '../language/src/nb.json'; -import en from '../language/src/en.json'; +import nb from '../../language/src/nb.json'; +import en from '../../language/src/en.json'; import { DEFAULT_LANGUAGE } from 'app-shared/constants'; import { useRepoStatusQuery } from 'app-shared/hooks/queries'; -import * as testids from '../testing/testids'; -import { PageRoutes } from './router/PageRoutes'; +import * as testids from '../../testing/testids'; const TEN_MINUTES_IN_MILLISECONDS = 600000; @@ -158,7 +157,7 @@ export function App() {

{t('session.inactive')}

- +
); diff --git a/frontend/app-development/layout/AppShell.test.tsx b/frontend/app-development/layout/PageLayout.test.tsx similarity index 96% rename from frontend/app-development/layout/AppShell.test.tsx rename to frontend/app-development/layout/PageLayout.test.tsx index c4248403305..3be31feab19 100644 --- a/frontend/app-development/layout/AppShell.test.tsx +++ b/frontend/app-development/layout/PageLayout.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { AppShell } from './AppShell'; +import { PageLayout } from './PageLayout'; import { screen, waitForElementToBeRemoved } from '@testing-library/react'; import { APP_DEVELOPMENT_BASENAME } from 'app-shared/constants'; import { renderWithProviders } from '../test/testUtils'; @@ -43,7 +43,7 @@ jest.mock('react-router-dom', () => ({ // Mocking console.error due to Tanstack Query removing custom logger between V4 and v5 see issue: #11692 const realConsole = console; -describe('App', () => { +describe('PageLayout', () => { beforeEach(() => { global.console = { ...console, @@ -112,7 +112,7 @@ const render = async (queries: Partial = {}) => { ...queries, }; - renderWithProviders(, { + renderWithProviders(, { startUrl: `${APP_DEVELOPMENT_BASENAME}/my-org/my-app/${RoutePaths.Overview}`, queries: allQueries, }); diff --git a/frontend/app-development/layout/AppShell.tsx b/frontend/app-development/layout/PageLayout.tsx similarity index 96% rename from frontend/app-development/layout/AppShell.tsx rename to frontend/app-development/layout/PageLayout.tsx index e169f4b2946..8988f2c9624 100644 --- a/frontend/app-development/layout/AppShell.tsx +++ b/frontend/app-development/layout/PageLayout.tsx @@ -9,7 +9,7 @@ import { MergeConflictWarning } from '../features/simpleMerge/MergeConflictWarni /** * Displays the layout for the app development pages */ -export const AppShell = (): React.ReactNode => { +export const PageLayout = (): React.ReactNode => { const { pathname } = useLocation(); const match = matchPath({ path: '/:org/:app', caseSensitive: true, end: false }, pathname); const { org, app } = match.params; diff --git a/frontend/app-development/package.json b/frontend/app-development/package.json index 7cb79ab2fa2..97c339f264d 100644 --- a/frontend/app-development/package.json +++ b/frontend/app-development/package.json @@ -21,7 +21,7 @@ "react-dom": "18.2.0", "react-i18next": "13.3.1", "react-redux": "8.1.3", - "react-router-dom": "6.18.0", + "react-router-dom": "6.20.0", "redux": "4.2.1", "redux-saga": "1.2.3", "reselect": "4.1.8" diff --git a/frontend/app-development/router/PageRoutes.module.css b/frontend/app-development/router/PageRoutes.module.css deleted file mode 100644 index 9b49d8dae29..00000000000 --- a/frontend/app-development/router/PageRoutes.module.css +++ /dev/null @@ -1,6 +0,0 @@ -.root { - display: flex; - flex-direction: column; - min-height: 100vh; - background: white; -} diff --git a/frontend/app-development/router/PageRoutes.tsx b/frontend/app-development/router/PageRoutes.tsx index 2b4206f965e..441e66d5579 100644 --- a/frontend/app-development/router/PageRoutes.tsx +++ b/frontend/app-development/router/PageRoutes.tsx @@ -1,30 +1,40 @@ import React from 'react'; -import classes from './PageRoutes.module.css'; -import { Navigate, Route, Routes } from 'react-router-dom'; -import { AppShell } from 'app-development/layout/AppShell'; +import { + RouterProvider, + createBrowserRouter, + createRoutesFromElements, + Navigate, + Route, +} from 'react-router-dom'; +import { App } from 'app-development/layout/App'; +import { PageLayout } from 'app-development/layout/PageLayout'; import { RoutePaths } from 'app-development/enums/RoutePaths'; import { routerRoutes } from 'app-development/router/routes'; import { StudioNotFoundPage } from '@studio/components'; +import { APP_DEVELOPMENT_BASENAME } from 'app-shared/constants'; const BASE_PATH = '/:org/:app'; +const router = createBrowserRouter( + createRoutesFromElements( + }> + }> + {/* Redirects from /:org/:app to child route /overview */} + } /> + {routerRoutes.map((route) => ( + } /> + ))} + } /> + + } /> + , + ), + { + basename: APP_DEVELOPMENT_BASENAME, + }, +); + /** * Displays the routes for app development pages */ -export const PageRoutes = () => { - return ( -
- - }> - {/* Redirects from /:org/:app to child route /overview */} - } /> - {routerRoutes.map((route) => ( - } /> - ))} - } /> - - } /> - -
- ); -}; +export const PageRoutes = () => ; diff --git a/frontend/app-preview/package.json b/frontend/app-preview/package.json index 77450bd7d1a..80a78b6d8b2 100644 --- a/frontend/app-preview/package.json +++ b/frontend/app-preview/package.json @@ -15,7 +15,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-redux": "8.1.3", - "react-router-dom": "6.18.0" + "react-router-dom": "6.20.0" }, "devDependencies": { "cross-env": "7.0.3", diff --git a/frontend/dashboard/package.json b/frontend/dashboard/package.json index 6ae7d77798f..6ce959c34aa 100644 --- a/frontend/dashboard/package.json +++ b/frontend/dashboard/package.json @@ -12,7 +12,7 @@ "@mui/x-data-grid": "5.17.26", "react": "18.2.0", "react-dom": "18.2.0", - "react-router-dom": "6.18.0", + "react-router-dom": "6.20.0", "react-use": "17.4.0" }, "devDependencies": { diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index 83688b4a3d5..f1830ab12f6 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -651,6 +651,7 @@ "process_editor.unknown_heading_error_message": "Obs, noe gikk galt!", "process_editor.unknown_paragraph_error_message": "En feil oppstod ved innlasting av BPMN-prosessen. Undersøk at filen er på et gyldig BPMN-format.", "process_editor.unsaved_changes": "Du har {{ count }} ulagrede endringer", + "process_editor.unsaved_changes_confirmation_message": "Du har ulagrede endringer. Vil du forlate siden uten å lagre?", "process_editor.view_mode": "Visningsmodus", "receipt.attachments": "Vedlegg", "receipt.body": "Det er gjennomført en maskinell kontroll under utfylling, men vi tar forbehold om at det kan bli oppdaget feil under saksbehandlingen og at annen dokumentasjon kan være nødvendig. Vennligst oppgi referansenummer ved eventuelle henvendelser til etaten.", diff --git a/frontend/packages/process-editor/src/ProcessEditor.test.tsx b/frontend/packages/process-editor/src/ProcessEditor.test.tsx index e658663ea95..4c83df6c36c 100644 --- a/frontend/packages/process-editor/src/ProcessEditor.test.tsx +++ b/frontend/packages/process-editor/src/ProcessEditor.test.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { render, screen, act } from '@testing-library/react'; +import { render as rtlRender, screen, act } from '@testing-library/react'; import { ProcessEditor, ProcessEditorProps } from './ProcessEditor'; import { textMock } from '../../../testing/mocks/i18nMock'; import userEvent from '@testing-library/user-event'; +import { RouterProvider, createMemoryRouter } from 'react-router-dom'; const mockBPMNXML: string = ``; @@ -11,22 +12,34 @@ const mockAppLibVersion7: string = '7.0.3'; const mockOnSave = jest.fn(); +const defaultProps: ProcessEditorProps = { + bpmnXml: mockBPMNXML, + onSave: mockOnSave, + appLibVersion: mockAppLibVersion8, +}; + +const render = (props: Partial = {}) => { + const allProps = { ...defaultProps, ...props }; + const router = createMemoryRouter([ + { + path: '/', + element: , + }, + ]); + + return rtlRender(); +}; + describe('ProcessEditor', () => { afterEach(jest.clearAllMocks); - const defaultProps: ProcessEditorProps = { - bpmnXml: mockBPMNXML, - onSave: mockOnSave, - appLibVersion: mockAppLibVersion8, - }; - it('should render loading while bpmnXml is undefined', () => { - render(); + render({ bpmnXml: undefined }); expect(screen.getByTitle(textMock('process_editor.loading'))).toBeInTheDocument(); }); it('should render "NoBpmnFoundAlert" when bpmnXml is null', () => { - render(); + render({ bpmnXml: null }); expect( screen.getByRole('heading', { name: textMock('process_editor.fetch_bpmn_error_title'), @@ -38,7 +51,7 @@ describe('ProcessEditor', () => { it('should render "canvas" when bpmnXml is provided and default render is view-mode', async () => { // eslint-disable-next-line testing-library/no-unnecessary-act await act(() => { - render(); + render(); }); expect( @@ -48,7 +61,7 @@ describe('ProcessEditor', () => { it('does not display the alert when the version is 8 or newer', async () => { const user = userEvent.setup(); - render(); + render(); // Fix to remove act error await act(() => user.tab()); @@ -62,7 +75,7 @@ describe('ProcessEditor', () => { it('displays the alert when the version is 7 or older', async () => { const user = userEvent.setup(); - render(); + render({ appLibVersion: mockAppLibVersion7 }); // Fix to remove act error await act(() => user.tab()); diff --git a/frontend/packages/process-editor/src/components/Canvas/Canvas.test.tsx b/frontend/packages/process-editor/src/components/Canvas/Canvas.test.tsx index 1961d257357..7041eec6e1b 100644 --- a/frontend/packages/process-editor/src/components/Canvas/Canvas.test.tsx +++ b/frontend/packages/process-editor/src/components/Canvas/Canvas.test.tsx @@ -1,17 +1,36 @@ import React from 'react'; import { render as rtlRender, screen, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { Canvas, CanvasProps } from './Canvas'; +import { Canvas } from './Canvas'; import { textMock } from '../../../../../testing/mocks/i18nMock'; -import { BpmnContextProvider } from '../../contexts/BpmnContext'; +import { BpmnContextProvider, BpmnContextProviderProps } from '../../contexts/BpmnContext'; +import { RouterProvider, createMemoryRouter } from 'react-router-dom'; const mockOnSave = jest.fn(); const mockAppLibVersion8: string = '8.0.1'; const mockAppLibVersion7: string = '7.0.1'; -const defaultProps: CanvasProps = { - onSave: mockOnSave, +const defaultProps: BpmnContextProviderProps = { + appLibVersion: mockAppLibVersion8, + bpmnXml: '', + children: null, +}; + +const render = (props: Partial = {}) => { + const allProps = { ...defaultProps, ...props }; + const router = createMemoryRouter([ + { + path: '/', + element: ( + + + + ), + }, + ]); + + return rtlRender(); }; describe('Canvas', () => { @@ -19,7 +38,7 @@ describe('Canvas', () => { it('hides actionMenu when version is 7 or older', async () => { const user = userEvent.setup(); - render(mockAppLibVersion7); + render({ appLibVersion: mockAppLibVersion7 }); // Fix to remove act error await act(() => user.tab()); @@ -30,7 +49,7 @@ describe('Canvas', () => { it('shows actionMenu when version is 8 or newer', async () => { const user = userEvent.setup(); - render(mockAppLibVersion8); + render({ appLibVersion: mockAppLibVersion8 }); // Fix to remove act error await act(() => user.tab()); @@ -38,12 +57,4 @@ describe('Canvas', () => { const editButton = screen.getByRole('button', { name: textMock('process_editor.edit_mode') }); expect(editButton).toBeInTheDocument; }); - - const render = (appLibVersion: string) => { - return rtlRender( - - - , - ); - }; }); diff --git a/frontend/packages/process-editor/src/components/Canvas/Canvas.tsx b/frontend/packages/process-editor/src/components/Canvas/Canvas.tsx index dad5a9ca171..816afea5c64 100644 --- a/frontend/packages/process-editor/src/components/Canvas/Canvas.tsx +++ b/frontend/packages/process-editor/src/components/Canvas/Canvas.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import 'bpmn-js/dist/assets/diagram-js.css'; import 'bpmn-js/dist/assets/bpmn-js.css'; @@ -6,6 +7,7 @@ import 'bpmn-js/dist/assets/bpmn-font/css/bpmn-embedded.css'; import classes from './Canvas.module.css'; import { useBpmnContext } from '../../contexts/BpmnContext'; +import { useConfirmationDialogOnPageLeave } from 'app-shared/hooks/useConfirmationDialogOnPageLeave'; import { BPMNViewer } from './BPMNViewer'; import { BPMNEditor } from './BPMNEditor'; import { CanvasActionMenu } from './CanvasActionMenu'; @@ -24,8 +26,9 @@ export type CanvasProps = { * @returns {JSX.Element} - The rendered component */ export const Canvas = ({ onSave }: CanvasProps): JSX.Element => { - const { getUpdatedXml, isEditAllowed } = useBpmnContext(); + const { getUpdatedXml, isEditAllowed, numberOfUnsavedChanges } = useBpmnContext(); const [isEditorView, setIsEditorView] = useState(false); + const { t } = useTranslation(); const toggleViewModus = (): void => { setIsEditorView((prevIsEditorView) => !prevIsEditorView); @@ -35,6 +38,11 @@ export const Canvas = ({ onSave }: CanvasProps): JSX.Element => { onSave(await getUpdatedXml()); }; + useConfirmationDialogOnPageLeave( + Boolean(numberOfUnsavedChanges), + t('process_editor.unsaved_changes_confirmation_message'), + ); + return (
{isEditAllowed && ( diff --git a/frontend/packages/process-editor/src/contexts/BpmnContext.tsx b/frontend/packages/process-editor/src/contexts/BpmnContext.tsx index c3aba92f0c2..f2354532923 100644 --- a/frontend/packages/process-editor/src/contexts/BpmnContext.tsx +++ b/frontend/packages/process-editor/src/contexts/BpmnContext.tsx @@ -23,7 +23,7 @@ export const BpmnContext = createContext({ appLibVersion: '', }); -type BpmnContextProviderProps = { +export type BpmnContextProviderProps = { children: React.ReactNode; bpmnXml: string | undefined | null; appLibVersion: string; diff --git a/frontend/packages/shared/package.json b/frontend/packages/shared/package.json index 4d9d1865623..74c44dd4fe5 100644 --- a/frontend/packages/shared/package.json +++ b/frontend/packages/shared/package.json @@ -14,7 +14,7 @@ "react-dnd-html5-backend": "16.0.1", "react-dom": "18.2.0", "react-redux": "8.1.3", - "react-router-dom": "6.18.0", + "react-router-dom": "6.20.0", "react-select": "5.7.7", "redux-saga": "1.2.3" }, diff --git a/frontend/packages/shared/src/hooks/useConfirmationDialogOnPageLeave.test.tsx b/frontend/packages/shared/src/hooks/useConfirmationDialogOnPageLeave.test.tsx new file mode 100644 index 00000000000..5d4b2fa2c24 --- /dev/null +++ b/frontend/packages/shared/src/hooks/useConfirmationDialogOnPageLeave.test.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { act, render as rtlRender } from '@testing-library/react'; +import { useConfirmationDialogOnPageLeave } from './useConfirmationDialogOnPageLeave'; +import { RouterProvider, createMemoryRouter, useBeforeUnload } from 'react-router-dom'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useBeforeUnload: jest.fn(), +})); + +const confirmationMessage = 'test'; + +const Component = ({ showConfirmationDialog }: { showConfirmationDialog: boolean }) => { + useConfirmationDialogOnPageLeave(showConfirmationDialog, confirmationMessage); + return null; +}; + +const render = (showConfirmationDialog: boolean) => { + const router = createMemoryRouter([ + { + path: '/', + element: , + }, + { + path: '/test', + element: null, + }, + ]); + + const { rerender } = rtlRender(); + return { + rerender, + router, + }; +}; + +describe('useConfirmationDialogOnPageLeave', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call useBeforeUnload with the expected arguments', () => { + const showConfirmationDialog = true; + render(showConfirmationDialog); + + expect(useBeforeUnload).toHaveBeenCalledWith(expect.any(Function), { + capture: true, + }); + }); + + it('should prevent navigation if showConfirmationDialog is true', () => { + const event = { + type: 'beforeunload', + returnValue: confirmationMessage, + } as BeforeUnloadEvent; + event.preventDefault = jest.fn(); + + const showConfirmationDialog = true; + render(showConfirmationDialog); + + const callbackFn = (useBeforeUnload as jest.MockedFunction).mock + .calls[0][0]; + callbackFn(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.returnValue).toBe(confirmationMessage); + }); + + it('should not prevent navigation if showConfirmationDialog is false', () => { + const event = { + type: 'beforeunload', + returnValue: '', + } as BeforeUnloadEvent; + event.preventDefault = jest.fn(); + + const showConfirmationDialog = false; + render(showConfirmationDialog); + + const callbackFn = (useBeforeUnload as jest.MockedFunction).mock + .calls[0][0]; + callbackFn(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(event.returnValue).toBe(''); + }); + + it('doesnt show confirmation dialog when there are no unsaved changes', async () => { + window.confirm = jest.fn(); + + const showConfirmationDialog = false; + const { router } = render(showConfirmationDialog); + + await act(async () => { + await router.navigate('/test'); + }); + + expect(window.confirm).toHaveBeenCalledTimes(0); + expect(router.state.location.pathname).toBe('/test'); + }); + + it('show confirmation dialog when there are unsaved changes', async () => { + window.confirm = jest.fn(); + + const showConfirmationDialog = true; + const { router } = render(showConfirmationDialog); + + await act(async () => { + await router.navigate('/test'); + }); + + expect(window.confirm).toHaveBeenCalledTimes(1); + expect(router.state.location.pathname).toBe('/'); + }); + + it('cancel redirection when clicking cancel', async () => { + window.confirm = jest.fn(() => false); + + const showConfirmationDialog = true; + const { router } = render(showConfirmationDialog); + + await act(async () => { + await router.navigate('/test'); + }); + + expect(window.confirm).toHaveBeenCalledTimes(1); + expect(router.state.location.pathname).toBe('/'); + }); + + it('redirect when clicking OK', async () => { + window.confirm = jest.fn(() => true); + + const showConfirmationDialog = true; + const { router } = render(showConfirmationDialog); + + await act(async () => { + await router.navigate('/test'); + }); + + expect(window.confirm).toHaveBeenCalledTimes(1); + expect(router.state.location.pathname).toBe('/test'); + }); +}); diff --git a/frontend/packages/shared/src/hooks/useConfirmationDialogOnPageLeave.ts b/frontend/packages/shared/src/hooks/useConfirmationDialogOnPageLeave.ts new file mode 100644 index 00000000000..24b7a993d0c --- /dev/null +++ b/frontend/packages/shared/src/hooks/useConfirmationDialogOnPageLeave.ts @@ -0,0 +1,34 @@ +import { useCallback, useEffect } from 'react'; +import { useBeforeUnload, useBlocker } from 'react-router-dom'; + +export const useConfirmationDialogOnPageLeave = ( + showConfirmationDialog: boolean, + confirmationMessage: string, +) => { + useBeforeUnload( + useCallback( + (event: BeforeUnloadEvent) => { + if (showConfirmationDialog) { + event.preventDefault(); + event.returnValue = confirmationMessage; + } + }, + [showConfirmationDialog, confirmationMessage], + ), + { capture: true }, + ); + + const blocker = useBlocker(({ currentLocation, nextLocation }) => { + return showConfirmationDialog && currentLocation.pathname !== nextLocation.pathname; + }); + + useEffect(() => { + if (blocker.state === 'blocked') { + if (window.confirm(confirmationMessage)) { + blocker.proceed(); + } else { + blocker.reset(); + } + } + }, [blocker, confirmationMessage]); +}; diff --git a/frontend/resourceadm/package.json b/frontend/resourceadm/package.json index 71c832237b1..b0b621c8835 100644 --- a/frontend/resourceadm/package.json +++ b/frontend/resourceadm/package.json @@ -12,7 +12,7 @@ "@mui/x-data-grid": "5.17.26", "react": "18.2.0", "react-dom": "18.2.0", - "react-router-dom": "6.18.0", + "react-router-dom": "6.20.0", "react-use": "17.4.0" }, "devDependencies": { diff --git a/package.json b/package.json index a11ab547b23..d5330568b3a 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,10 @@ "@tanstack/react-query-devtools": "5.8.1", "ajv": "8.12.0", "ajv-formats": "2.1.1", - "react-error-boundary": "^4.0.11", + "react-error-boundary": "4.0.11", "react-i18next": "13.3.1", - "react-router-dom": "6.18.0", - "react-toastify": "^9.1.3" + "react-router-dom": "6.20.0", + "react-toastify": "9.1.3" }, "devDependencies": { "@emotion/react": "11.11.1", @@ -60,7 +60,7 @@ "lint-staged": "15.1.0", "mini-css-extract-plugin": "2.7.6", "msw": "1.3.2", - "prettier": "^3.0.3", + "prettier": "3.0.3", "react": "18.2.0", "react-dom": "18.2.0", "redux-mock-store": "1.5.4", diff --git a/yarn.lock b/yarn.lock index 3018d53ebd5..77f40340a4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3763,10 +3763,10 @@ __metadata: languageName: node linkType: hard -"@remix-run/router@npm:1.11.0": - version: 1.11.0 - resolution: "@remix-run/router@npm:1.11.0" - checksum: 629ec578b9dfd3c5cb5de64a0798dd7846ec5ba0351aa66f42b1c65efb43da8f30366be59b825303648965b0df55b638c110949b24ef94fd62e98117fdfb0c0f +"@remix-run/router@npm:1.13.0": + version: 1.13.0 + resolution: "@remix-run/router@npm:1.13.0" + checksum: bb173a012d2036c5ee69babfe30c73975b970c2e5a0edaba138c302ae80d255e238e462e77365ab4efe819b6397e1a7f3a416d6200d17f9655f0ca1c51c4f45e languageName: node linkType: hard @@ -5606,13 +5606,13 @@ __metadata: lint-staged: "npm:15.1.0" mini-css-extract-plugin: "npm:2.7.6" msw: "npm:1.3.2" - prettier: "npm:^3.0.3" + prettier: "npm:3.0.3" react: "npm:18.2.0" react-dom: "npm:18.2.0" - react-error-boundary: "npm:^4.0.11" + react-error-boundary: "npm:4.0.11" react-i18next: "npm:13.3.1" - react-router-dom: "npm:6.18.0" - react-toastify: "npm:^9.1.3" + react-router-dom: "npm:6.20.0" + react-toastify: "npm:9.1.3" redux-mock-store: "npm:1.5.4" redux-saga: "npm:1.2.3" redux-saga-test-plan: "npm:4.0.6" @@ -5740,7 +5740,7 @@ __metadata: react-dom: "npm:18.2.0" react-i18next: "npm:13.3.1" react-redux: "npm:8.1.3" - react-router-dom: "npm:6.18.0" + react-router-dom: "npm:6.20.0" redux: "npm:4.2.1" redux-saga: "npm:1.2.3" reselect: "npm:4.1.8" @@ -5762,7 +5762,7 @@ __metadata: react: "npm:18.2.0" react-dom: "npm:18.2.0" react-redux: "npm:8.1.3" - react-router-dom: "npm:6.18.0" + react-router-dom: "npm:6.20.0" typescript: "npm:5.2.2" webpack: "npm:5.89.0" webpack-dev-server: "npm:4.15.1" @@ -5786,7 +5786,7 @@ __metadata: react-dnd-html5-backend: "npm:16.0.1" react-dom: "npm:18.2.0" react-redux: "npm:8.1.3" - react-router-dom: "npm:6.18.0" + react-router-dom: "npm:6.20.0" react-select: "npm:5.7.7" redux-saga: "npm:1.2.3" typescript: "npm:5.2.2" @@ -7630,7 +7630,7 @@ __metadata: jest: "npm:29.7.0" react: "npm:18.2.0" react-dom: "npm:18.2.0" - react-router-dom: "npm:6.18.0" + react-router-dom: "npm:6.20.0" react-use: "npm:17.4.0" typescript: "npm:5.2.2" webpack: "npm:5.89.0" @@ -14054,7 +14054,7 @@ __metadata: languageName: node linkType: hard -"prettier@npm:^3.0.3": +"prettier@npm:3.0.3": version: 3.0.3 resolution: "prettier@npm:3.0.3" bin: @@ -14370,7 +14370,7 @@ __metadata: languageName: node linkType: hard -"react-error-boundary@npm:^4.0.11": +"react-error-boundary@npm:4.0.11": version: 4.0.11 resolution: "react-error-boundary@npm:4.0.11" dependencies: @@ -14534,27 +14534,27 @@ __metadata: languageName: node linkType: hard -"react-router-dom@npm:6.18.0": - version: 6.18.0 - resolution: "react-router-dom@npm:6.18.0" +"react-router-dom@npm:6.20.0": + version: 6.20.0 + resolution: "react-router-dom@npm:6.20.0" dependencies: - "@remix-run/router": "npm:1.11.0" - react-router: "npm:6.18.0" + "@remix-run/router": "npm:1.13.0" + react-router: "npm:6.20.0" peerDependencies: react: ">=16.8" react-dom: ">=16.8" - checksum: b0e72603d73172b6c6662afe2faed326753d5bbd9905aa560e3dade7996fc13d19e34e3ed668d2849efd685e2db2f711129c84b1439870e92c9cc91ddc343cf5 + checksum: 4b6741c545cedf5a5c4f996deb953679dcc985425e0664e27b97fdb9ab1387cbe1a6a12bfc7f7c38ec40b15759b4bf6396930ec26540a4a81ae16d154fd35049 languageName: node linkType: hard -"react-router@npm:6.18.0": - version: 6.18.0 - resolution: "react-router@npm:6.18.0" +"react-router@npm:6.20.0": + version: 6.20.0 + resolution: "react-router@npm:6.20.0" dependencies: - "@remix-run/router": "npm:1.11.0" + "@remix-run/router": "npm:1.13.0" peerDependencies: react: ">=16.8" - checksum: a00c8f347b7ffee575f4a7731782e688e3fca458ca5bd970fb41cef66a6851853caa24464155ab438d5879f367b1223a539642a405a865913ffe7e63e53b1245 + checksum: 2cdac5ad8b7a7bc230173b26768bcf3f6a9abc0a19983fa7b76b9ffdbeb44bfbd88fcc2033e9062defafef144db207859eb3162a9c9742d70cfce4e7166ff1e5 languageName: node linkType: hard @@ -14595,7 +14595,7 @@ __metadata: languageName: node linkType: hard -"react-toastify@npm:^9.1.3": +"react-toastify@npm:9.1.3": version: 9.1.3 resolution: "react-toastify@npm:9.1.3" dependencies: @@ -15090,7 +15090,7 @@ __metadata: jest: "npm:29.7.0" react: "npm:18.2.0" react-dom: "npm:18.2.0" - react-router-dom: "npm:6.18.0" + react-router-dom: "npm:6.20.0" react-use: "npm:17.4.0" typescript: "npm:5.2.2" webpack: "npm:5.89.0"