App
App copied to clipboard
[$250] Add confirmation dialog on unsaved changes
Problem
When users fill in details in the app (e.g. description on an expense) and tap back or the scrim/overlay by mistake, the user loses everything they filled it. This can be particularly annoying if you submit an expense and fill in multiple details only to misclick and have to start over. We saw this multiple times during our usability study.
Solution
To remedy this, let's add a confirmation dialog on these sort of interactions by throwing up a confirmation dialog for unsaved changes. Here's a video to demonstrate:
https://github.com/user-attachments/assets/36948029-5b52-43fb-aa14-bea2a3011f70
Notes:
- If the value isn't changed, we should show the warning.
- Clicking
Discardcontinues the action of the user (if back, go back. If scrim/overlay, closer the right-hand-pane) without saving the value. - Any movement away from the screen should trigger the confirmation dialog (given the value has changed)
Questions:
- Can this be applied systematically or do we have to go through all flows? If not, we should probably start with
Submit expenseto try it out and continue from there.
cc @Expensify/design @JmillsExpensify @trjExpensify
Upwork Automation - Do Not Edit
- Upwork Job URL: https://www.upwork.com/jobs/~021864985847950506437
- Upwork Job ID: 1864985847950506437
- Last Price Increase: 2024-12-06
Issue Owner
Current Issue Owner: @ahmedGaber93
Edited by proposal-police: This proposal was edited at 2024-12-06 05:23:17 UTC.
Proposal
Please re-state the problem that we are trying to solve in this issue.
Add confirmation dialog on unsaved changes
What is the root cause of that problem?
New feature
What changes do you think we should make in order to solve the problem?
- Create a new component
DiscardChangeModalbased onConfirmModal - Create a new state like
isShowDiscardChangeModal - Will set
isShowDiscardChangeModalas true when moving away from the screen without saving the value.
What specific scenarios should we cover in automated tests to prevent reintroducing this issue in the future?
NA
What alternative solutions did you explore? (Optional)
Reminder: Please use plain English, be brief and avoid jargon. Feel free to use images, charts or pseudo-code if necessary. Do not post large multi-line diffs or write walls of text. Do not create PRs unless you have been hired for this job.
Triggered auto assignment to @abekkala (NewFeature), see https://stackoverflowteams.com/c/expensify/questions/14418#:~:text=BugZero%20process%20steps%20for%20feature%20requests for more details. Please add this Feature request to a GH project, as outlined in the SO.
:warning: It looks like this issue is labelled as a New Feature but not tied to any GitHub Project. Keep in mind that all new features should be tied to GitHub Projects in order to properly track external CAP software time :warning:
@dubielzyk-expensify make sure you add Bug or New feature labels to issues like this, to get it assigned out to a BZ member to manage and progress.
Triggered auto assignment to Design team member for new feature review - @dubielzyk-expensify (NewFeature)
Job added to Upwork: https://www.upwork.com/jobs/~021864985847950506437
Triggered auto assignment to Contributor-plus team member for initial proposal review - @ahmedGaber93 (External)
Proposal
Please re-state the problem that we are trying to solve in this issue.
Add confirmation dialog on unsaved changes
What is the root cause of that problem?
New Feature
What changes do you think we should make in order to solve the problem?
If we start from IOURequestStepDescription page,
https://github.com/Expensify/App/blob/a9bf599522215bc8c844d587374fd4f04d6b860b/src/pages/iou/request/step/IOURequestStepDescription.tsx#L57
- we can use the already existing
ConfirmModal - modify the function used for
onBackButtonPress
https://github.com/Expensify/App/blob/a9bf599522215bc8c844d587374fd4f04d6b860b/src/pages/iou/request/step/IOURequestStepDescription.tsx#L139
to use a modified function as:
const handleNavigation = () => {
if (newDescription !== currentDescription) {
setIsConfirmationModalVisible(true);
} else {
Navigation.goBack(backTo);
}
};
- add this:
const [isConfirmationModalVisible, setIsConfirmationModalVisible] = useState(false);
<ConfirmModal
title={'Discard Changes?'}
prompt='Are you sure you want to discard the changes you made?'
isVisible={isConfirmationModalVisible}
onConfirm={()=>{
setIsConfirmationModalVisible(false)
Navigation.goBack(backTo);
}}
onCancel={()=>{
setIsConfirmationModalVisible(false)
}}
confirmText={'Discard Changed'}
cancelText={'Cancel'}
danger
/>
we should create translation for the above used texts. We can add the same ConfirmModal in all the pages needed.
https://github.com/user-attachments/assets/e350c6f6-93b1-4052-a0e0-9069de091b63
Edited by proposal-police: This proposal was edited at 2024-12-10 07:13:19 UTC.
Proposal
Please re-state the problem that we are trying to solve in this issue.
We want to show a discard changes confirmation when closing the page if there is an unsaved changes.
What is the root cause of that problem?
New feature.
What changes do you think we should make in order to solve the problem?
Since we want to apply this to many pages, we can create a new component called DiscardChangesConfirmation. This component will make use of beforeRemove event and prevent default if there is an unsaved changes. If the user press cancel, then we simply close the confirm modal, else, we continue with the navigation action that is previously prevented.
const DiscardChangesConfirmation = ({getHasUnsavedChanges}) => {
const navigation = useNavigation();
const [isVisible, setIsVisible] = useState(false);
const preventedNavigationAction = useRef();
const handlePreventRemove = useCallback((e) => {
if (!getHasUnsavedChanges()) {
return;
}
e.preventDefault();
blockedNavigationAction.current = e.data.action;
setIsVisible(true);
}, [getHasUnsavedChanges]);
useEffect(() => {
navigation.addListener('beforeRemove', handlePreventRemove);
return () => navigation.removeListener('beforeRemove', handlePreventRemove);
}, [handlePreventRemove]);
return (
<ConfirmModal
isVisible={isVisible}
title='Discard changes?'
prompt='Are you sure?'
danger
confirmText='Discard changes'
cancelText='Cancel'
onConfirm={() => {
setIsVisible(false);
navigation.dispatch(preventedNavigationAction.current)
}}
onCancel={() => setIsVisible(false)}
/>
)
}
Then, to use this in the description page for example, we need to update the input first to be a controlled input. https://github.com/Expensify/App/blob/7d42188f6070d11c3964397b60246ba9d300d8d7/src/pages/iou/request/step/IOURequestStepDescription.tsx#L153-L158
const [description, setDescription] = useState(currentDescription);
<InputWrapper
valueType="string"
value={description}
onValueChange={setDescription}
Then, add a isSavedRef to indicate that the user saves the value so we don't show discard confirm modal when the user saves the changes.
const isSavedRef = useRef(false);
And set it to true here. https://github.com/Expensify/App/blob/8981081bed8fb8a07f8797d49af2797332c9259d/src/pages/iou/request/step/IOURequestStepDescription.tsx#L101-L102
And use the new component like this:
<DiscardChangesConfirmation
getHasUnsavedChanges={() => {
if (isSavedRef.current) {
return false;
}
return description !== currentDescription
}}
/>
What specific scenarios should we cover in automated tests to prevent reintroducing this issue in the future?
N/A
π£ @bjmmtin! π£ Hey, it seems we donβt have your contributor details yet! You'll only have to do this once, and this is how we'll hire you on Upwork. Please follow these steps:
- Make sure you've read and understood the contributing guidelines.
- Get the email address used to login to your Expensify account. If you don't already have an Expensify account, create one here. If you have multiple accounts (e.g. one for testing), please use your main account email.
- Get the link to your Upwork profile. It's necessary because we only pay via Upwork. You can access it by logging in, and then clicking on your name. It'll look like this. If you don't already have an account, sign up for one here.
- Copy the format below and paste it in a comment on this issue. Replace the placeholder text with your actual details.
Format:
Contributor details
Your Expensify account email: <REPLACE EMAIL HERE>
Upwork Profile Link: <REPLACE LINK HERE>
react-hook-form simplifies form management by providing easy-to-use hooks for registering inputs, handling submissions, and tracking form state. useBlocker effectively prevents users from accidentally losing unsaved changes by prompting them with a modal alert before navigating away from the form.
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useBlocker } from 'react-router-dom';
const MyForm = () => {
const { register, handleSubmit, formState: { isDirty } } = useForm({
defaultValues: { name: "", email: "" }
});
const [alertModal, setAlertModal] = useState({ show: false, title: "", content: "" });
const [proceed, setProceed] = useState(false);
const [navigateTo, setNavigateTo] = useState(null);
const onSubmit = (data) => {
console.log(data);
// Reset the form or perform any other action after submission
};
const handleNavigation = ({ nextLocation }) => {
if (isDirty) {
setProceed(true);
setNavigateTo(nextLocation.pathname);
setAlertModal({
show: true,
title: "You have unsaved changes.",
content: "All unsaved changes will be lost.",
});
return false; // Prevent navigation
}
return true; // Allow navigation
};
useBlocker(handleNavigation);
const handleAlertConfirm = () => {
setProceed(false);
setAlertModal({ show: false, title: "", content: "" });
// Navigate to the next location if proceed is true
if (navigateTo) {
// Use your navigation logic here, e.g., using useNavigate from react-router-dom
// navigate(navigateTo);
}
};
return (
<div>
{alertModal.show && (
<div className="alert-modal">
<h2>{alertModal.title}</h2>
<p>{alertModal.content}</p>
<button onClick={handleAlertConfirm}>Proceed</button>
<button onClick={() => setAlertModal({ show: false })}>Cancel</button>
</div>
)}
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} placeholder="Name" />
<input {...register('email')} placeholder="Email" />
<button type="submit">Submit</button>
</form>
</div>
);
};
export default MyForm;
Reviewing today
Proposal
Please re-state the problem that we are trying to solve in this issue.
Add confirmation dialog on unsaved changes
What is the root cause of that problem?
New feature
What changes do you think we should make in order to solve the problem?
- Test branch: https://github.com/mkzie2/App/tree/mkzie2-issue/53679
-
Create a new
DiscardChangesModalthat usesConfirmModal. To detect when the screen is removed, we can use thebeforeRemoveevent. If we click on the confirm button, continue the navigate action. If we click on the cancel button, close this modal. -
To apply this pattern to all pages, we can add this in
FormWrapperthen all pages that use a form always has this behavior.
- To do that firstly we need to detect whenever the value is changed. Define the
initInputValuesRefto store the form's initial value. That will be the first value that is not an empty object ofinputValues. Introduce a new propisValueChangedinFormWrapperhere and pass it hereisValueChanged={!deepEqual(inputValues, initInputValuesRef.current)}.
const initInputValuesRef = useRef<Form>();
useEffect(() => {
if (!!initInputValuesRef.current || isEmptyObject(inputValues)) {
return;
}
initInputValuesRef.current = inputValues;
}, [inputValues]);
- Then we can add
DiscardChangesModalhere withhasUnsavedChanges={!isValueChanged}
<DiscardChangesModal hasUnsavedChanges={!isValueChanged} />
https://github.com/Expensify/App/blob/37105a1b4c60ba6416b3ae3415c2265a53c55257/src/components/Form/FormWrapper.tsx#L125
Optional: If we don't want to apply for all forms of input, we can introduce a new prop in FormProvider shouldUseDiscardChangesModal and will only render DiscardChangesModal if shouldUseDiscardChangesModal is true. Then we will pass shouldUseDiscardChangesModal as true to FormProvider in all places that we want to use this feature.
What specific scenarios should we cover in automated tests to prevent reintroducing this issue in the future?
What alternative solutions did you explore? (Optional)
Reminder: Please use plain English, be brief and avoid jargon. Feel free to use images, charts or pseudo-code if necessary. Do not post large multi-line diffs or write walls of text. Do not create PRs unless you have been hired for this job.
@bernhardoj I love your idea using beforeRemove, And I see you used it before here.
But I have some question:
- How we will prevent the
beforeRemovethat will trigger after clicksave? - What is about the limitation with
@react-navigation/native-stack(back gesture doesn't triggerbeforeRemoveevent with native-stack)? This limitation has been fixed in version 7.x, but we use version 6.9
now we are using the high version react-router-dom, It's impossible to use the beforeRemove, it can't prevent the navigation and won't trigger after click save. we can implement it only by using useBlocker.
How we will prevent the beforeRemove that will trigger after click save?
Updated to handle this case
What is about the limitation with @react-navigation/native-stack (back gesture doesn't trigger beforeRemove event with native-stack)? This limitation has been fixed in version 7.x, but we use version 6.9
I think the only option we have is to disable the swipe gesture or update the navigation to 7.0 and use usePreventRemove hook.
@ahmedGaber93 What do you think about my proposal? This can apply to all places where we use a FormProvider.
@bernhardoj, can we know how many places/forms are use @react-navigation/native-stack and will be affected with this limitation, I believe RHP will not be affected
What do you think about my proposal?
@mkzie2 I think it has the same idea from bernhardoj proposal https://github.com/Expensify/App/issues/53679#issuecomment-2523352545
can we know how many places/forms are use @react-navigation/native-stack and will be affected with this limitation, I believe RHP will not be affected
I think most of the screens are using native stack already (except web). RHP uses createPlatformStackNavigatorComponent which uses the native stack. From my testing, the discard modal won't show if we use the swipe gesture to close the screen on iOS.
@bernhardoj's proposal using beforeRemove listener can do the job with some limitations, I think the important limitation is it will not support back gesture in iOS util we upgrade @react-navigation/native-stack to v7.x to use usePreventRemove (now we use v6.9).
I think we can move forward @bernhardoj's proposal, and open a tracker issue to support back gesture in iOS when @react-navigation/native-stack get upgrade.
π π π C+ reviewed
Triggered auto assignment to @stitesExpensify, see https://stackoverflow.com/c/expensify/questions/7972 for more details.
π£ @ahmedGaber93 π An offer has been automatically sent to your Upwork account for the Reviewer role π Thanks for contributing to the Expensify app!
PR is ready
cc: @ahmedGaber93
This issue has not been updated in over 15 days. @abekkala, @ahmedGaber93, @stitesExpensify, @bernhardoj, @dubielzyk-expensify eroding to Monthly issue.
P.S. Is everyone reading this sure this is really a near-term priority? Be brave: if you disagree, go ahead and close it out. If someone disagrees, they'll reopen it, and if they don't: one less thing to do!
Deployed to staging
Reviewing label has been removed, please complete the "BugZero Checklist".
The solution for this issue has been :rocket: deployed to production :rocket: in version 9.0.82-12 and is now subject to a 7-day regression period :calendar:. Here is the list of pull requests that resolve this issue:
- https://github.com/Expensify/App/pull/54176
If no regressions arise, payment will be issued on 2025-01-17. :confetti_ball:
For reference, here are some details about the assignees on this issue:
- @ahmedGaber93 requires payment automatic offer (Reviewer)
- @bernhardoj requires payment through NewDot Manual Requests
BugZero Checklist: The PR adding this new feature has been merged! The following checklist (instructions) will need to be completed before the issue can be closed:
- [ ] [@ahmedGaber93] Please propose regression test steps to ensure the new feature will work correctly on production in further releases.
- [ ] [@abekkala] Link the GH issue for creating/updating the regression test once above steps have been agreed upon.
BugZero Checklist: The PR adding this new feature has been merged! The following checklist (instructions) will need to be completed before the issue can be closed:
- [x] [@ahmedGaber93] Please propose regression test steps to ensure the new feature will work correctly on production in further releases.
Regression Test Proposal
- Open submit expense flow page
- Enter amount and proceed to the confirmation page
- Open the Description form and keep the input empty
- Go back
- Verify the page closes
- Open the page again
- Type anything
- Go back
- Verify there is a discard confirmation modal
- Press Cancel
- Verify the modal closes, but the page doesn't close
- Repeat step 8-9
- Press Discard changes
- Verify the modal and page closes
- Repeat step 6-7
- Save the changes
- Verify the page closes and the change is saved
- Repeat the same test (step 3-17) for Merchant field and Verify it has the same behavior in steps 5, 9, 11, 14, 17.