Skip to content

Commit

Permalink
Merge pull request #831 from PADAS/ERA-8169
Browse files Browse the repository at this point in the history
ERA-8169: Confirmation modal when navigating away from unsaved changes
  • Loading branch information
luixlive committed Feb 7, 2023
2 parents 8fa09c7 + c89410a commit 4afbe94
Show file tree
Hide file tree
Showing 19 changed files with 867 additions and 65 deletions.
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 }) => {
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());

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');
});
});
});
67 changes: 65 additions & 2 deletions src/NavigationContextProvider/index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,74 @@
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 [blockRequestIds, setBlockRequestIds] = useState([]);
const [blockerState, setBlockerState] = useState(BLOCKER_STATES.UNBLOCKED);
const [navigationData, setNavigationData] = useState({});

const navigationContextValue = { navigationData, setNavigationData };
const isNavigationBlocked = !!blockRequestIds.length;

const blockNavigation = useCallback((newBlockRequestId) => {
setBlockRequestIds((blockRequestIds) => [...blockRequestIds, newBlockRequestId]);
}, []);

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

const unblockNavigation = useCallback((blockRequestIdToRemove) => {
setBlockRequestIds(
(blockRequestIds) => blockRequestIds.filter((blockRequestId) => blockRequestId !== blockRequestIdToRemove)
);
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
113 changes: 97 additions & 16 deletions src/NavigationContextProvider/index.test.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,107 @@
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', () => {
const blockRequestId = '123';

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

expect(result.current.navigationData).toEqual({});

result.current.setNavigationData('Navigation data!');

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

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

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

result.current.unblockNavigation(blockRequestId);

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

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

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(result.current.blocker.state).toBe(BLOCKER_STATES.UNBLOCKED);
});

test('stays blocked if a different blocker request id is removed', async () => {
const anotherBlockRequestId = '456';

const wrapper = ({ children }) => <NavigationContextProvider>{children}</NavigationContextProvider>;
const { result } = renderHook(() => useContext(NavigationContext), { wrapper });

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

result.current.blockNavigation(blockRequestId);
result.current.blockNavigation(anotherBlockRequestId);

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

useEffect(() => {
setNavigationData('Navigation data!');
}, [setNavigationData]);
result.current.unblockNavigation(anotherBlockRequestId);

return <p>{JSON.stringify(navigationData)}</p>;
};
expect(result.current.isNavigationBlocked).toBeTruthy();

render(
<NavigationContextProvider>
<ChildComponent />
</NavigationContextProvider>
);
result.current.unblockNavigation(blockRequestId);

expect((await screen.findByText('"Navigation data!"'))).toBeDefined();
expect(result.current.isNavigationBlocked).toBeFalsy();
});
});

0 comments on commit 4afbe94

Please sign in to comment.