Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ERA-8169: Confirmation modal when navigating away from unsaved changes #831

Merged
merged 10 commits into from
Feb 7, 2023
2 changes: 1 addition & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ REACT_APP_BASE_MAP_STYLES='mapbox://styles/vjoelm/ciobuir0n0061bdnj1c54oakh?opti

# Feature flags
REACT_APP_ENABLE_PATROL_NEW_UI=false
REACT_APP_ENABLE_REPORT_NEW_UI=false
REACT_APP_ENABLE_REPORT_NEW_UI=true
REACT_APP_ENABLE_GEOPERMISSION_UI=true
REACT_APP_ENABLE_EVENT_GEOMETRY=true
50 changes: 50 additions & 0 deletions src/Link/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';

import { BLOCKER_STATES, NavigationContext } from '../NavigationContextProvider';

const Link = ({ onClick, ...rest }) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Custom Link wrapper that "adds" our blocking functionality on top of the normal Link

const { blocker, isNavigationBlocked, onNavigationAttemptBlocked } = useContext(NavigationContext);

const blockedEventRef = useRef();
const linkRef = useRef();

const [skipBlocker, setSkipBlocker] = useState(false);
const [wasClicked, setWasClicked] = useState(false);

const handleClick = useCallback((event) => {
if (!skipBlocker && isNavigationBlocked) {
event.preventDefault();

setWasClicked(true);
blockedEventRef.current = event;
onNavigationAttemptBlocked();
} else {
onClick?.(blockedEventRef.current || event);

setSkipBlocker(false);
blockedEventRef.current = null;
}
}, [isNavigationBlocked, onClick, onNavigationAttemptBlocked, skipBlocker]);

useEffect(() => {
if (wasClicked && blocker.state === BLOCKER_STATES.PROCEEDING) {
setSkipBlocker(true);
setWasClicked(false);

setTimeout(() => linkRef.current.click());
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we lose anything by not forwarding the original blocked click event through to the handler?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm that's a good question, didn't thought about it. I couldn't think of anything, but it should be pretty easy to store the event on a ref or something. Just a question, Idk how to forward an event, is it as easy as:


setTimeout(() => linkRef.current.click(blockedEventRef.current));

??


blocker.reset();
}
}, [blocker, onNavigationAttemptBlocked, wasClicked]);

useEffect(() => {
if (blocker.state === BLOCKER_STATES.UNBLOCKED) {
setWasClicked(false);
}
}, [blocker.state]);

return <RouterLink onClick={handleClick} ref={linkRef} {...rest} />;
};

export default Link;
76 changes: 76 additions & 0 deletions src/Link/index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import React from 'react';
import { useLocation } from 'react-router-dom';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import Link from './';
import { BLOCKER_STATES, NavigationContext } from '../NavigationContextProvider';
import NavigationWrapper from '../__test-helpers/navigationWrapper';

describe('Link', () => {
const LocationDisplay = () => {
const location = useLocation();

return <div data-testid="location-display">{location.pathname}</div>;
};

let onNavigationAttemptBlocked = jest.fn(), reset = jest.fn();
const blocker = { reset, state: BLOCKER_STATES.UNBLOCKED };

test('navigates to the link route when user clicks it', async () => {
render(<NavigationWrapper>
<Link to="/route" />

<LocationDisplay />
</NavigationWrapper>);

const link = await screen.findByRole('link');
userEvent.click(link);

expect((await screen.findByTestId('location-display'))).toHaveTextContent('/route');
});

test('blocks a navigation attempt when navigation is blocked', async () => {
render(<NavigationWrapper>
<NavigationContext.Provider value={{
blocker,
isNavigationBlocked: true,
onNavigationAttemptBlocked,
}}>
<Link to="/route" />

<LocationDisplay />
</NavigationContext.Provider>
</NavigationWrapper>);

const link = await screen.findByRole('link');
userEvent.click(link);

expect((await screen.findByTestId('location-display'))).toHaveTextContent('/');
});

test('blocks a navigation attempt and unblocks it after the user decides to continue', async () => {
blocker.state = BLOCKER_STATES.PROCEEDING;

render(<NavigationWrapper>
<NavigationContext.Provider value={{
blocker,
isNavigationBlocked: true,
onNavigationAttemptBlocked,
}}>
<Link to="/route" />

<LocationDisplay />
</NavigationContext.Provider>
</NavigationWrapper>);

const link = await screen.findByRole('link');
userEvent.click(link);

expect((await screen.findByTestId('location-display'))).toHaveTextContent('/');

await waitFor(async () => {
expect((await screen.findByTestId('location-display'))).toHaveTextContent('/route');
});
});
});
61 changes: 59 additions & 2 deletions src/NavigationContextProvider/index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,68 @@
import React, { createContext, useState } from 'react';
import React, { createContext, useCallback, useEffect, useState } from 'react';

export const NavigationContext = createContext();

export const BLOCKER_STATES = {
BLOCKED: 'blocked',
PROCEEDING: 'proceeding',
UNBLOCKED: 'unblocked',
};

const NavigationContextProvider = ({ children }) => {
const [blockerState, setBlockerState] = useState(BLOCKER_STATES.UNBLOCKED);
const [isNavigationBlocked, setIsNavigationBlocked] = useState(false);
const [navigationData, setNavigationData] = useState({});

const navigationContextValue = { navigationData, setNavigationData };
const blockNavigation = useCallback(() => setIsNavigationBlocked(true), []);

const onNavigationAttemptBlocked = useCallback(() => {
if (isNavigationBlocked) {
setBlockerState(BLOCKER_STATES.BLOCKED);
}
}, [isNavigationBlocked]);

const unblockNavigation = useCallback(() => {
setIsNavigationBlocked(false);
setBlockerState(BLOCKER_STATES.UNBLOCKED);
}, []);

useEffect(() => {
if (isNavigationBlocked) {
const onUnload = (event) => {
event.returnValue = 'Would you like to discard changes?';
};
window.addEventListener('beforeunload', onUnload);

return () => {
window.removeEventListener('beforeunload', onUnload);
};
}
}, [isNavigationBlocked]);

const proceed = useCallback(() => {
if (blockerState === BLOCKER_STATES.BLOCKED) {
setBlockerState(BLOCKER_STATES.PROCEEDING);
}
}, [blockerState]);

const reset = useCallback(() => {
if (blockerState === BLOCKER_STATES.BLOCKED) {
setBlockerState(BLOCKER_STATES.UNBLOCKED);
}
}, [blockerState]);

const blocker = { proceed, reset, state: blockerState };

const navigationContextValue = {
blocker,
isNavigationBlocked,
navigationData,

blockNavigation,
onNavigationAttemptBlocked,
setNavigationData,
unblockNavigation,
};

return <NavigationContext.Provider value={navigationContextValue}>
{children}
Expand Down
89 changes: 73 additions & 16 deletions src/NavigationContextProvider/index.test.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,83 @@
import React, { useContext, useEffect } from 'react';
import { render, screen } from '@testing-library/react';
import React, { useContext } from 'react';
import { renderHook } from '@testing-library/react-hooks';

import NavigationContextProvider, { NavigationContext } from './';
import NavigationContextProvider, { BLOCKER_STATES, NavigationContext } from './';

describe('NavigationContextProvider', () => {
test('can read and update navigation data', async () => {
const ChildComponent = () => {
const { navigationData, setNavigationData } = useContext(NavigationContext);
const wrapper = ({ children }) => <NavigationContextProvider>{children}</NavigationContextProvider>;
const { result } = renderHook(() => useContext(NavigationContext), { wrapper });

useEffect(() => {
setNavigationData('Navigation data!');
}, [setNavigationData]);
expect(result.current.navigationData).toEqual({});

return <p>{JSON.stringify(navigationData)}</p>;
};
result.current.setNavigationData('Navigation data!');

render(
<NavigationContextProvider>
<ChildComponent />
</NavigationContextProvider>
);
expect(result.current.navigationData).toBe('Navigation data!');
});

test('blocks the navigation', async () => {
const wrapper = ({ children }) => <NavigationContextProvider>{children}</NavigationContextProvider>;
const { result } = renderHook(() => useContext(NavigationContext), { wrapper });

expect(result.current.isNavigationBlocked).toBeFalsy();

result.current.blockNavigation();

expect(result.current.isNavigationBlocked).toBeTruthy();
});

test('unblocks the navigation', async () => {
const wrapper = ({ children }) => <NavigationContextProvider>{children}</NavigationContextProvider>;
const { result } = renderHook(() => useContext(NavigationContext), { wrapper });

expect(result.current.isNavigationBlocked).toBeFalsy();

result.current.blockNavigation();

expect(result.current.isNavigationBlocked).toBeTruthy();

result.current.unblockNavigation();

expect(result.current.isNavigationBlocked).toBeFalsy();
});

test('sets the blocker proceeding state', async () => {
const wrapper = ({ children }) => <NavigationContextProvider>{children}</NavigationContextProvider>;
const { result } = renderHook(() => useContext(NavigationContext), { wrapper });

expect(result.current.isNavigationBlocked).toBeFalsy();

result.current.blockNavigation();

expect(result.current.isNavigationBlocked).toBeTruthy();
expect(result.current.blocker.state).toBe(BLOCKER_STATES.UNBLOCKED);

result.current.onNavigationAttemptBlocked();

expect(result.current.blocker.state).toBe(BLOCKER_STATES.BLOCKED);

result.current.blocker.proceed();

expect(result.current.blocker.state).toBe(BLOCKER_STATES.PROCEEDING);
});

test('sets the blocker unblocked state', async () => {
const wrapper = ({ children }) => <NavigationContextProvider>{children}</NavigationContextProvider>;
const { result } = renderHook(() => useContext(NavigationContext), { wrapper });

expect(result.current.isNavigationBlocked).toBeFalsy();

result.current.blockNavigation();

expect(result.current.isNavigationBlocked).toBeTruthy();
expect(result.current.blocker.state).toBe(BLOCKER_STATES.UNBLOCKED);

result.current.onNavigationAttemptBlocked();

expect(result.current.blocker.state).toBe(BLOCKER_STATES.BLOCKED);

result.current.blocker.reset();

expect((await screen.findByText('"Navigation data!"'))).toBeDefined();
expect(result.current.blocker.state).toBe(BLOCKER_STATES.UNBLOCKED);
});
});
63 changes: 63 additions & 0 deletions src/NavigationPromptModal/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React, { memo } from 'react';
import Button from 'react-bootstrap/Button';
import Modal from 'react-bootstrap/Modal';
import PropTypes from 'prop-types';

import { ReactComponent as TrashCanIcon } from '../common/images/icons/trash-can.svg';

import { BLOCKER_STATES } from '../NavigationContextProvider';
import useNavigationBlocker from '../hooks/useNavigationBlocker';

import styles from './styles.module.scss';

const NavigationPromptModal = ({
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New prompt component that automatically blocks the navigation (if when prop is true) and shows a modal asking for user interaction to continue with the redirection or cancel it.

cancelNavigationButtonText,
continueNavigationButtonText,
description,
title,
when,
...rest
}) => {
const blocker = useNavigationBlocker(when);

return <Modal {...rest} onHide={blocker.reset} show={blocker.state === BLOCKER_STATES.BLOCKED}>
<Modal.Header closeButton>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>

<Modal.Body>{description}</Modal.Body>

<Modal.Footer className={styles.footer}>
<Button className={styles.cancelButton} onClick={blocker.reset} variant="secondary">
{cancelNavigationButtonText}
</Button>

<Button
className={styles.continueButton}
onClick={blocker.proceed}
onFocus={(event) => event.target.blur()}
variant="primary"
>
<TrashCanIcon />
{continueNavigationButtonText}
</Button>
</Modal.Footer>
</Modal>;
};

NavigationPromptModal.defaultProps = {
cancelNavigationButtonText: 'Cancel',
continueNavigationButtonText: 'Discard',
description: 'Would you like to discard changes?',
title: 'Discard changes',
};

NavigationPromptModal.propTypes = {
cancelNavigationButtonText: PropTypes.string,
continueNavigationButtonText: PropTypes.string,
description: PropTypes.string,
title: PropTypes.string,
when: PropTypes.bool.isRequired,
};

export default memo(NavigationPromptModal);