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 6 commits
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
4 changes: 3 additions & 1 deletion src/hooks/useBeforeRemove.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {useNavigation} from '@react-navigation/native';
import type {EventListenerCallback, EventMapCore, NavigationState} from '@react-navigation/native';
import {useEffect} from 'react';

const useBeforeRemove = (onBeforeRemove: () => void) => {
// beforeRemove have some limitations. When the react-navigation is upgraded to 7.x, update this to use usePreventRemove hook.
const useBeforeRemove = (onBeforeRemove: EventListenerCallback<EventMapCore<NavigationState>, 'beforeRemove'>) => {
const navigation = useNavigation();

useEffect(() => {
Expand Down
43 changes: 43 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5575,6 +5575,49 @@ const translations = {
crossPlatform: 'Do <strong>everything</strong> from your phone or browser',
},
},
productTrainingTooltip: {
conciergeLHNGBR: {
part1: 'Get started',
part2: ' here!',
},
saveSearchTooltip: {
part1: 'Rename your saved searches',
part2: ' here!',
},
quickActionButton: {
part1: 'Quick action!',
part2: ' Just a tap away',
},
workspaceChatCreate: {
part1: 'Submit your',
part2: ' expenses',
part3: ' here!',
},
searchFilterButtonTooltip: {
part1: 'Customize your search',
part2: ' here!',
},
bottomNavInboxTooltip: {
part1: 'Your to-do list',
part2: '\n🟢 = ready for you',
part3: ' 🔴 = needs review',
},
workspaceChatTooltip: {
part1: 'Submit expenses',
part2: ' and chat with',
part3: '\napprovers here!',
},
globalCreateTooltip: {
part1: 'Create expenses',
part2: ', start chatting,',
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>;
43 changes: 43 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6094,6 +6094,49 @@ const translations = {
crossPlatform: 'Haz <strong>todo</strong> desde tu teléfono o navegador',
},
},
productTrainingTooltip: {
conciergeLHNGBR: {
part1: '¡Comienza',
part2: ' aquí!',
},
saveSearchTooltip: {
part1: 'Renombra tus búsquedas guardadas',
part2: ' aquí',
},
quickActionButton: {
part1: '¡Acción rápida!',
part2: ' A solo un toque',
},
workspaceChatCreate: {
part1: 'Envía tus',
part2: ' gastos',
part3: ' aquí',
},
searchFilterButtonTooltip: {
part1: 'Personaliza tu búsqueda',
part2: ' aquí!',
},
bottomNavInboxTooltip: {
part1: 'Tu lista de tareas',
part2: '\n🟢 = listo para ti',
part3: ' 🔴 = necesita revisión',
},
workspaceChatTooltip: {
part1: 'Envía gastos',
part2: ' y chatea con',
part3: '\naprobadores aquí!',
},
globalCreateTooltip: {
part1: 'Crea gastos',
part2: ', comienza a chatear,',
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);
125 changes: 50 additions & 75 deletions src/pages/iou/request/step/IOURequestStepDescription.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import lodashIsEmpty from 'lodash/isEmpty';
import React, {useCallback} from 'react';
import React, {useCallback, useMemo, useRef} from 'react';
import {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
import {useOnyx} from 'react-native-onyx';
import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
import TextInput from '@components/TextInput';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useLocalize from '@hooks/useLocalize';
import usePolicy from '@hooks/usePolicy';
import useThemeStyles from '@hooks/useThemeStyles';
import * as ErrorUtils from '@libs/ErrorUtils';
import * as IOUUtils from '@libs/IOUUtils';
Expand All @@ -23,56 +24,49 @@ 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';
import withWritableReportOrNotFound from './withWritableReportOrNotFound';

type IOURequestStepDescriptionOnyxProps = {
/** The draft transaction that holds data to be persisted on the current transaction */
splitDraftTransaction: OnyxEntry<OnyxTypes.Transaction>;

/** The policy of the report */
policy: OnyxEntry<OnyxTypes.Policy>;

/** Collection of categories attached to a policy */
policyCategories: OnyxEntry<OnyxTypes.PolicyCategories>;

/** Collection of tags attached to a policy */
policyTags: OnyxEntry<OnyxTypes.PolicyTagLists>;

/** The actions from the parent report */
reportActions: OnyxEntry<OnyxTypes.ReportActions>;

/** Session info for the currently logged in user. */
session: OnyxEntry<OnyxTypes.Session>;
type IOURequestStepDescriptionProps = WithWritableReportOrNotFoundProps<typeof SCREENS.MONEY_REQUEST.STEP_DESCRIPTION> & {
/** Holds data related to Expense view state, rather than the underlying Expense data. */
transaction: OnyxEntry<OnyxTypes.Transaction>;
};

type IOURequestStepDescriptionProps = IOURequestStepDescriptionOnyxProps &
WithWritableReportOrNotFoundProps<typeof SCREENS.MONEY_REQUEST.STEP_DESCRIPTION> & {
/** Holds data related to Expense view state, rather than the underlying Expense data. */
transaction: OnyxEntry<OnyxTypes.Transaction>;
};

function IOURequestStepDescription({
route: {
params: {action, iouType, reportID, backTo, reportActionID},
params: {action, iouType, reportID, backTo, reportActionID, transactionID},
},
transaction,
splitDraftTransaction,
policy,
policyTags,
policyCategories,
reportActions,
session,
report,
}: IOURequestStepDescriptionProps) {
const policy = usePolicy(report?.policyID);
const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`);
const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`);
const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report?.policyID}`);
const reportActionsReportID = useMemo(() => {
let actionsReportID;
if (action === CONST.IOU.ACTION.EDIT) {
actionsReportID = iouType === CONST.IOU.TYPE.SPLIT ? report?.reportID : report?.parentReportID;
}
return actionsReportID;
}, [action, iouType, report?.reportID, report?.parentReportID]);
const [reportAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportActionsReportID}`, {
canEvict: false,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
selector: (reportActions) => reportActions?.[report?.parentReportActionID || reportActionID],
});
const [session] = useOnyx(ONYXKEYS.SESSION);
const styles = useThemeStyles();
const {translate} = useLocalize();
const {inputCallbackRef} = useAutoFocusInput(true);
// 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 +92,16 @@ function IOURequestStepDescription({
Navigation.goBack(backTo);
};

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

const updateComment = (value: FormOnyxValues<typeof ONYXKEYS.FORMS.MONEY_REQUEST_DESCRIPTION_FORM>) => {
if (!transaction?.transactionID) {
return;
}

isSavedRef.current = true;
const newComment = value.moneyRequestComment.trim();

// Only update comment if it has changed
Expand All @@ -109,23 +112,21 @@ function IOURequestStepDescription({

// In the split flow, when editing we use SPLIT_TRANSACTION_DRAFT to save draft value
if (isEditingSplitBill) {
IOU.setDraftSplitTransaction(transaction?.transactionID ?? '-1', {comment: newComment});
IOU.setDraftSplitTransaction(transaction?.transactionID, {comment: newComment});
navigateBack();
return;
}
const isTransactionDraft = IOUUtils.shouldUseTransactionDraft(action);

IOU.setMoneyRequestDescription(transaction?.transactionID ?? '-1', newComment, isTransactionDraft);
IOU.setMoneyRequestDescription(transaction?.transactionID, newComment, isTransactionDraft);

if (action === CONST.IOU.ACTION.EDIT) {
IOU.updateMoneyRequestDescription(transaction?.transactionID ?? '-1', reportID, newComment, policy, policyTags, policyCategories);
IOU.updateMoneyRequestDescription(transaction?.transactionID, reportID, newComment, policy, policyTags, policyCategories);
}

navigateBack();
};

// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- nullish coalescing doesn't achieve the same result in this case
const reportAction = reportActions?.[report?.parentReportActionID || reportActionID] ?? null;
const isEditing = action === CONST.IOU.ACTION.EDIT;
const isSplitBill = iouType === CONST.IOU.TYPE.SPLIT;
const canEditSplitBill = isSplitBill && reportAction && session?.accountID === reportAction.actorAccountID && TransactionUtils.areRequiredFieldsEmpty(transaction);
Expand All @@ -151,10 +152,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,50 +170,22 @@ function IOURequestStepDescription({
/>
</View>
</FormProvider>
<DiscardChangesConfirmation
getHasUnsavedChanges={() => {
if (isSavedRef.current) {
return false;
}
return descriptionRef.current !== currentDescription;
}}
/>
</StepScreenWrapper>
);
}

IOURequestStepDescription.displayName = 'IOURequestStepDescription';

const IOURequestStepDescriptionWithOnyx = withOnyx<IOURequestStepDescriptionProps, IOURequestStepDescriptionOnyxProps>({
splitDraftTransaction: {
key: ({route}) => {
const transactionID = route?.params.transactionID ?? -1;
return `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`;
},
},
policy: {
key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '-1'}`,
},
policyCategories: {
key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report ? report.policyID : '-1'}`,
},
policyTags: {
key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report ? report.policyID : '-1'}`,
},
reportActions: {
key: ({
report,
route: {
params: {action, iouType},
},
}) => {
let reportID = '-1';
if (action === CONST.IOU.ACTION.EDIT) {
reportID = iouType === CONST.IOU.TYPE.SPLIT ? report?.reportID ?? '-1' : report?.parentReportID ?? '-1';
}
return `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`;
},
canEvict: false,
},
session: {
key: ONYXKEYS.SESSION,
},
})(IOURequestStepDescription);

// eslint-disable-next-line rulesdir/no-negated-variables
const IOURequestStepDescriptionWithFullTransactionOrNotFound = withFullTransactionOrNotFound(IOURequestStepDescriptionWithOnyx);
const IOURequestStepDescriptionWithFullTransactionOrNotFound = withFullTransactionOrNotFound(IOURequestStepDescription);
// eslint-disable-next-line rulesdir/no-negated-variables
const IOURequestStepDescriptionWithWritableReportOrNotFound = withWritableReportOrNotFound(IOURequestStepDescriptionWithFullTransactionOrNotFound);

Expand Down
Loading
Loading