-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #831 from PADAS/ERA-8169
ERA-8169: Confirmation modal when navigating away from unsaved changes
- Loading branch information
Showing
19 changed files
with
867 additions
and
65 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
Oops, something went wrong.