-
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
Conversation
… message in report detail view
|
||
import { NavigationContext } from '../NavigationContextProvider'; | ||
|
||
const Link = ({ onClick, ...rest }) => { |
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 normal Link
|
||
export const NavigationContext = createContext(); | ||
|
||
const NavigationContextProvider = ({ children }) => { | ||
const [isNavigationAttemptPending, setIsNavigationAttemptPending] = useState(false); | ||
const [isNavigationBlocked, setIsNavigationBlocked] = useState(false); | ||
const [navigationAttemptResolution, setNavigationAttemptResolution] = useState(null); |
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.
isNavigationAttemptPending
: true when the navigation is blocked and the user tried to leave the page, so the app is waiting for a resolution on that attempt.
isNavigationBlocked
: means the navigation is currently blocked in the app, intercepting every Link and useNavigate navigation attempts
navigationAttemptResolution
: null while there's no resolution. It can only have a non-null value when there is a navigation attempt pending and the user chose to either continue with it (sets this flag as true) or cancel it (sets it to false).
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.
The phrasing here is pretty confusing. For example: a "resolution" is usually a noun describing the way something was resolved. I would expect a state container holding a resolution to contain what happens on resolution, not a boolean flag for if something is resolved.
Likewise, the verbiage around blocked, pending, attempted, etc could be simplified
|
||
import styles from './styles.module.scss'; | ||
|
||
const NavigationPromptModal = ({ |
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.
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.
|
||
import { NavigationContext } from '../../NavigationContextProvider'; | ||
|
||
const useBlockNavigation = (when) => { |
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.
New hook to block navigation if when
is true and return the necessary properties to handle a navigation attempt.
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.
I just noticed the following:
I think there should be no confirmation for this case
Screen.Recording.2023-01-17.at.11.27.10.mov
@Alcoto95 we should have that covered now 😎 |
I noticed a couple more things with the latest commit:
Screen.Recording.2023-01-18.at.16.15.04.mov
Screen.Recording.2023-01-18.at.16.17.36.movI can't create an incident by adding an additional event without updating anything from that second event in its details. We should be able to add the event without updating any details. |
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.
Thanks for the fixes! LGTM now 🎉 don't forget to turn off the FF
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.
|
||
export const NavigationContext = createContext(); | ||
|
||
const NavigationContextProvider = ({ children }) => { | ||
const [isNavigationAttemptPending, setIsNavigationAttemptPending] = useState(false); | ||
const [isNavigationBlocked, setIsNavigationBlocked] = useState(false); | ||
const [navigationAttemptResolution, setNavigationAttemptResolution] = useState(null); |
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.
The phrasing here is pretty confusing. For example: a "resolution" is usually a noun describing the way something was resolved. I would expect a state container holding a resolution to contain what happens on resolution, not a boolean flag for if something is resolved.
Likewise, the verbiage around blocked, pending, attempted, etc could be simplified
src/Link/index.js
Outdated
|
||
const linkRef = useRef(); | ||
|
||
const [localNavigationAttemptBlocked, setLocalNavigationAttemptBlocked] = useState(false); |
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.
The pattern of having a context-inherited value and also a locally-scoped identical value feels wonky to me
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.
Yeah... It is because we can have multiple link components rendered in the page at the same time and all of them would try to autoclick itself every time a blocked navigation attempt is "resolved". So I had to somehow keep a internal state that tells if this link is the one that the user tried to click.
src/Link/index.js
Outdated
const linkRef = useRef(); | ||
|
||
const [localNavigationAttemptBlocked, setLocalNavigationAttemptBlocked] = useState(false); | ||
const [localNavigationUnblocked, setLocalNavigationUnblocked] = useState(false); |
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.
Wouldn't you want to initialize these state values with the initial values coming from useContext(NavigationContext)
?
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.
Hmm 🤔 yeah, I think you're right
src/Link/index.js
Outdated
event.preventDefault(); | ||
|
||
setLocalNavigationAttemptBlocked(true); | ||
navigationAttemptBlocked(); |
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.
This is an event handler with no on
prefix, which is probably part of why it reads so strangely when trying to parse the code
@JoshuaVulcan about the browser navigation, I couldn't find a way to make it work with Tiffany's designs. We have the event |
…ompt for browser events
@JoshuaVulcan I tried to implement |
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.
Looking quite good! A few questions and comments, mostly ready to fly 🚀 🚀 🚀
setSkipBlocker(true); | ||
setWasClicked(false); | ||
|
||
setTimeout(() => linkRef.current.click()); |
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.
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 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));
??
}); | ||
|
||
test('unblocks the navigation', async () => { | ||
const ChildComponent = () => { |
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.
I'm seeing some funky test patterns here. For example, in unblocks the navigation
, you're blocking and then immediately unblocking navigation without verifying the "blocked" state in the lifespan of the test (yes it's tested separately in the previous test, but that's different); this means if it ever fails in this test, you wouldn't know.
Since you're only really testing output values from useContext
in a lot of these tests, why not use renderHook
with the provider as the wrapper?
Like this: https://react-hooks-testing-library.com/usage/advanced-hooks#context
Then you could directly verify the outcomes as returned values, rather than rendering placeholder components and reading the stringified output.
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.
Taking a look at that 👀
@@ -4,7 +4,15 @@ import isEqual from 'react-fast-compare'; | |||
|
|||
export const extractObjectDifference = (object, original) => transform(object, (result, value, key) => { | |||
if (!isEqual(value, original[key])) { | |||
result[key] = isPlainObject(value) && isPlainObject(original[key]) ? extractObjectDifference(value, original[key]) : value; |
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.
Can you elaborate on these changes? It's a core utility, it would be good to know about possible side effects in other portions of the code.
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.
Yes! It is good that you mention this. So you can see that this portion:
result[key] = isPlainObject(value) && isPlainObject(original[key]) ? extractObjectDifference(value, original[key]) : value;
is logically exactly the same than:
if (isPlainObject(value) && isPlainObject(original[key])) {
result[key] = extractObjectDifference(value, original[key]);
} else {
result[key] = value;
}
Just some syntax change. But, in the first branch of the condition I added a second validation:
if (Object.keys(objectDifference).length > 0) {
result[key] = objectDifference;
}
Basically, if the extractObjectDifference
function returns an empty object, I don't add it to the result.
The reasoning behind is that sometimes we got something like this when extracting the difference from the original and the edited report:
{ someProperty: {} }
Which basically means that there is no really a difference between the two, just that it found some empty property in one that is not present in the second one. Technically, there is no difference between the forms, but for some reason at some point we added an empty object as property in one of them. It happened in a specific scenario in collections, I can't remember the name of the property, I could easily find it if I remove these changes. But the issue was that the save button was enabled and the confirmation modal was showing up even if the user didn't change anything in the report collection. We depend on the output of this function to enable those two things.
…avigation context tests
@JoshuaVulcan ready for another pass. I'd just like to know your thoughts about my comment in the utility function change |
@luixlive ready for FF toggle and merge |
…rop to NavigationPrompt to force its visibility
@JoshuaVulcan @Alcoto95 my last commit fixes the case of the second added report. It does it by:
|
@luixlive LGTM! |
Ticket: https://allenai.atlassian.net/browse/ERA-8169
Figma: https://www.figma.com/file/1u3VbK9kbOEuUg9Wi8AHRW/Patrol-%26-Report-UI-Refresh-FINAL?node-id=3141%3A29671&t=YcmsZAmHiNIzQQZH-0
Env: https://era-8169.pamdas.org/
Description
So, this is a tricky one. This PR introduces the capability to "block" navigation attempts through
useNavigate
hook andLink
component in order to show prompt messages when the user tries to leave, for example, an incomplete (dirty) form. This functionality used to be included inreact-router
(search for<Prompt>
oruseBlocker
in v5). Those functionalities are supposedly a WIP in v6, but there's no specified release date (refer to remix-run/react-router#8139).This PR then includes a new hook
useBlockNavigation
(I'm open to name suggestions) that receives a single boolean parameter to block / unblock the navigation. For example we may want to block it if a form is modified, but keep it unblocked if it's not. And it returns an object with three properties:isNavigationAttemptPending
: boolean set as true if the user tried to leave the page. While the variable is true it means that the app is in a pending navigation state, until we call one of the following methods.cancelNavigationAttempt
: method to tell the navigation logic that the pending navigation attempt can be ignoredcontinueNavigationAttempt
: method to tell the navigation logic that the pending navigation attempt should be addressed (thus redirecting the user to the desired page)This logic is actually not invented by me, but what I found to be the common pattern to handle navigation blockers in React. Just implemented by me 🤠 The navigation blocking, navigation attempt and navigation attempt resolution logic is abstracted in our
NavigationContextProvider
and our customuseNavigate
and the newLink
components.The PR also includes a new
NavigationPromptModal
that receives a single required propwhen
and a few customization optional props to change the modal title, description and buttons content. It uses theuseBlockNavigation
hook internally to block the navigation when the propwhen
is true. Whenever there is a pending attempt to navigate, the prompt appears asking the user to choose between canceling or continuing the attempt. This component makes it really easy to reuse all the navigation blocking logic showing a prompt modal as it's intended in the report and patrol new UI.