-
Notifications
You must be signed in to change notification settings - Fork 0
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
Changes from all commits
f09d3b1
a006fd7
2f04614
08e54c1
55195cd
638130d
a808fc1
b29c891
69a0324
c89410a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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()); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
?? |
||
|
||
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; |
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'); | ||
}); | ||
}); | ||
}); |
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(); | ||
}); | ||
}); |
There was a problem hiding this comment.
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 normalLink