Skip to content
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

Show discard confirmation modal when going back from a page with unsaved changes #54176

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/hooks/useBeforeRemove.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {useNavigation} from '@react-navigation/native';
import type {EventListenerCallback, EventMapCore, NavigationState} from '@react-navigation/native';
import {useEffect} from 'react';

const useBeforeRemove = (onBeforeRemove: () => void) => {
const useBeforeRemove = (onBeforeRemove: EventListenerCallback<EventMapCore<NavigationState>, 'beforeRemove'>) => {
const navigation = useNavigation();

useEffect(() => {
Expand Down
5 changes: 5 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5497,6 +5497,11 @@ const translations = {
part3: '\nand more!',
},
},
discardChangesConfirmation: {
title: 'Discard changes?',
body: 'Are you sure you want to discard the changes you made?',
confirmText: 'Discard changes',
},
};

export default translations satisfies TranslationDeepObject<typeof translations>;
5 changes: 5 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6017,6 +6017,11 @@ const translations = {
part3: '\ny mucho más!',
},
},
discardChangesConfirmation: {
title: '¿Descartar cambios?',
body: '¿Estás seguro de que quieres descartar los cambios que hiciste?',
confirmText: 'Descartar cambios',
},
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Asked for the translation verification here: https://expensify.slack.com/archives/C01GTK53T8Q/p1734322609246139

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It approved, Let's add the Slack link in author checklist Link to Slack message:

};

export default translations satisfies TranslationDeepObject<typeof en>;
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import useSideModalStackScreenOptions from '@libs/Navigation/AppNavigator/useSid
import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import type {AuthScreensParamList, RightModalNavigatorParamList} from '@navigation/types';
import CONST from '@src/CONST';
import NAVIGATORS from '@src/NAVIGATORS';
import SCREENS from '@src/SCREENS';
import Overlay from './Overlay';
Expand Down Expand Up @@ -53,6 +54,9 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) {
}
isExecutingRef.current = true;
navigation.goBack();
setTimeout(() => {
isExecutingRef.current = false;
}, CONST.ANIMATED_TRANSITION);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isExecutingRef prevent the user to click the overlay multiple times when going back (I can't repro the multiple click issue even after removing the ref). But since we are preventing the going back when there is unsaved change, the next press won't do anything. So, we need to reset isExecutingRef back.

}}
/>
)}
Expand Down
52 changes: 52 additions & 0 deletions src/pages/iou/request/step/DiscardChangesConfirmation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {useNavigation} from '@react-navigation/native';
import type {NavigationAction} from '@react-navigation/native';
import React, {memo, useCallback, useRef, useState} from 'react';
import ConfirmModal from '@components/ConfirmModal';
import useBeforeRemove from '@hooks/useBeforeRemove';
import useLocalize from '@hooks/useLocalize';

type DiscardChangesConfirmationProps = {
getHasUnsavedChanges: () => boolean;
};

function DiscardChangesConfirmation({getHasUnsavedChanges}: DiscardChangesConfirmationProps) {
const navigation = useNavigation();
const {translate} = useLocalize();
const [isVisible, setIsVisible] = useState(false);
const blockedNavigationAction = useRef<NavigationAction>();

useBeforeRemove(
useCallback(
(e) => {
if (!getHasUnsavedChanges()) {
return;
}

e.preventDefault();
blockedNavigationAction.current = e.data.action;
setIsVisible(true);
},
[getHasUnsavedChanges],
),
);

return (
<ConfirmModal
isVisible={isVisible}
title={translate('discardChangesConfirmation.title')}
prompt={translate('discardChangesConfirmation.body')}
danger
confirmText={translate('discardChangesConfirmation.confirmText')}
cancelText={translate('common.cancel')}
onConfirm={() => {
setIsVisible(false);
if (blockedNavigationAction.current) {
navigation.dispatch(blockedNavigationAction.current);
}
}}
onCancel={() => setIsVisible(false)}
/>
);
}

export default memo(DiscardChangesConfirmation);
20 changes: 19 additions & 1 deletion src/pages/iou/request/step/IOURequestStepDescription.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import lodashIsEmpty from 'lodash/isEmpty';
import React, {useCallback} from 'react';
import React, {useCallback, useRef} from 'react';
import {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
Expand All @@ -23,6 +23,7 @@ import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
import INPUT_IDS from '@src/types/form/MoneyRequestDescriptionForm';
import type * as OnyxTypes from '@src/types/onyx';
import DiscardChangesConfirmation from './DiscardChangesConfirmation';
import StepScreenWrapper from './StepScreenWrapper';
import withFullTransactionOrNotFound from './withFullTransactionOrNotFound';
import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound';
Expand Down Expand Up @@ -73,6 +74,8 @@ function IOURequestStepDescription({
// In the split flow, when editing we use SPLIT_TRANSACTION_DRAFT to save draft value
const isEditingSplitBill = iouType === CONST.IOU.TYPE.SPLIT && action === CONST.IOU.ACTION.EDIT;
const currentDescription = isEditingSplitBill && !lodashIsEmpty(splitDraftTransaction) ? splitDraftTransaction?.comment?.comment ?? '' : transaction?.comment?.comment ?? '';
const descriptionRef = useRef(currentDescription);
const isSavedRef = useRef(false);

/**
* @returns - An object containing the errors for each inputID
Expand All @@ -98,7 +101,12 @@ function IOURequestStepDescription({
Navigation.goBack(backTo);
};

const updateDescriptionRef = (value: string) => {
descriptionRef.current = value;
};

const updateComment = (value: FormOnyxValues<typeof ONYXKEYS.FORMS.MONEY_REQUEST_DESCRIPTION_FORM>) => {
isSavedRef.current = true;
const newComment = value.moneyRequestComment.trim();

// Only update comment if it has changed
Expand Down Expand Up @@ -151,10 +159,12 @@ function IOURequestStepDescription({
>
<View style={styles.mb4}>
<InputWrapper
valueType="string"
InputComponent={TextInput}
inputID={INPUT_IDS.MONEY_REQUEST_COMMENT}
name={INPUT_IDS.MONEY_REQUEST_COMMENT}
defaultValue={currentDescription}
onValueChange={updateDescriptionRef}
label={translate('moneyRequestConfirmationList.whatsItFor')}
accessibilityLabel={translate('moneyRequestConfirmationList.whatsItFor')}
role={CONST.ROLE.PRESENTATION}
Expand All @@ -167,6 +177,14 @@ function IOURequestStepDescription({
/>
</View>
</FormProvider>
<DiscardChangesConfirmation
getHasUnsavedChanges={() => {
if (isSavedRef.current) {
return false;
}
return descriptionRef.current !== currentDescription;
}}
/>
</StepScreenWrapper>
);
}
Expand Down
23 changes: 21 additions & 2 deletions src/pages/iou/request/step/IOURequestStepMerchant.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, {useCallback} from 'react';
import React, {useCallback, useRef} from 'react';
import {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
Expand All @@ -18,6 +18,7 @@ import type SCREENS from '@src/SCREENS';
import INPUT_IDS from '@src/types/form/MoneyRequestMerchantForm';
import type * as OnyxTypes from '@src/types/onyx';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import DiscardChangesConfirmation from './DiscardChangesConfirmation';
import StepScreenWrapper from './StepScreenWrapper';
import type {WithFullTransactionOrNotFoundProps} from './withFullTransactionOrNotFound';
import withFullTransactionOrNotFound from './withFullTransactionOrNotFound';
Expand Down Expand Up @@ -62,6 +63,9 @@ function IOURequestStepMerchant({
const isEditingSplitBill = iouType === CONST.IOU.TYPE.SPLIT && isEditing;
const merchant = ReportUtils.getTransactionDetails(isEditingSplitBill && !isEmptyObject(splitDraftTransaction) ? splitDraftTransaction : transaction)?.merchant;
const isEmptyMerchant = merchant === '' || merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT;
const initialMerchant = isEmptyMerchant ? '' : merchant;
const merchantRef = useRef(initialMerchant);
const isSavedRef = useRef(false);

const isMerchantRequired =
ReportUtils.isPolicyExpenseChat(report) || ReportUtils.isExpenseRequest(report) || transaction?.participants?.some((participant) => !!participant.isPolicyExpenseChat);
Expand All @@ -83,7 +87,12 @@ function IOURequestStepMerchant({
[isMerchantRequired, translate],
);

const updateMerchantRef = (value: string) => {
merchantRef.current = value;
};

const updateMerchant = (value: FormOnyxValues<typeof ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM>) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used ref here (same for desc) so it won't trigger a re-render everytime the value is updated since we only use this for getHasUnsavedChanges.

isSavedRef.current = true;
const newMerchant = value.moneyRequestMerchant?.trim();

// In the split flow, when editing we use SPLIT_TRANSACTION_DRAFT to save draft value
Expand Down Expand Up @@ -124,10 +133,12 @@ function IOURequestStepMerchant({
>
<View style={styles.mb4}>
<InputWrapper
valueType="string"
InputComponent={TextInput}
inputID={INPUT_IDS.MONEY_REQUEST_MERCHANT}
name={INPUT_IDS.MONEY_REQUEST_MERCHANT}
defaultValue={isEmptyMerchant ? '' : merchant}
defaultValue={initialMerchant}
onValueChange={updateMerchantRef}
maxLength={CONST.MERCHANT_NAME_MAX_LENGTH}
label={translate('common.merchant')}
accessibilityLabel={translate('common.merchant')}
Expand All @@ -136,6 +147,14 @@ function IOURequestStepMerchant({
/>
</View>
</FormProvider>
<DiscardChangesConfirmation
getHasUnsavedChanges={() => {
if (isSavedRef.current) {
return false;
}
return merchantRef.current !== initialMerchant;
}}
/>
</StepScreenWrapper>
);
}
Expand Down
Loading