diff --git a/src/hooks/useBeforeRemove.tsx b/src/hooks/useBeforeRemove.tsx index 91d1b93a41cc..835d5a30babe 100644 --- a/src/hooks/useBeforeRemove.tsx +++ b/src/hooks/useBeforeRemove.tsx @@ -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, 'beforeRemove'>) => { const navigation = useNavigation(); useEffect(() => { diff --git a/src/languages/en.ts b/src/languages/en.ts index 3d35fed3e0a4..a7b64cd6c1e4 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5604,6 +5604,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; diff --git a/src/languages/es.ts b/src/languages/es.ts index cb7f53424958..451065db30be 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6123,6 +6123,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', + }, }; export default translations satisfies TranslationDeepObject; diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index c996f380e98c..91533e66b5f0 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -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'; @@ -53,6 +54,9 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { } isExecutingRef.current = true; navigation.goBack(); + setTimeout(() => { + isExecutingRef.current = false; + }, CONST.ANIMATED_TRANSITION); }} /> )} diff --git a/src/pages/iou/request/step/DiscardChangesConfirmation.tsx b/src/pages/iou/request/step/DiscardChangesConfirmation.tsx new file mode 100644 index 000000000000..317547632a29 --- /dev/null +++ b/src/pages/iou/request/step/DiscardChangesConfirmation.tsx @@ -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(); + + useBeforeRemove( + useCallback( + (e) => { + if (!getHasUnsavedChanges()) { + return; + } + + e.preventDefault(); + blockedNavigationAction.current = e.data.action; + setIsVisible(true); + }, + [getHasUnsavedChanges], + ), + ); + + return ( + { + setIsVisible(false); + if (blockedNavigationAction.current) { + navigation.dispatch(blockedNavigationAction.current); + } + }} + onCancel={() => setIsVisible(false)} + /> + ); +} + +export default memo(DiscardChangesConfirmation); diff --git a/src/pages/iou/request/step/IOURequestStepDescription.tsx b/src/pages/iou/request/step/IOURequestStepDescription.tsx index a39261f08b8c..f4a8ee827ff1 100644 --- a/src/pages/iou/request/step/IOURequestStepDescription.tsx +++ b/src/pages/iou/request/step/IOURequestStepDescription.tsx @@ -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'; @@ -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; - - /** The policy of the report */ - policy: OnyxEntry; - - /** Collection of categories attached to a policy */ - policyCategories: OnyxEntry; - - /** Collection of tags attached to a policy */ - policyTags: OnyxEntry; - - /** The actions from the parent report */ - reportActions: OnyxEntry; - - /** Session info for the currently logged in user. */ - session: OnyxEntry; +type IOURequestStepDescriptionProps = WithWritableReportOrNotFoundProps & { + /** Holds data related to Expense view state, rather than the underlying Expense data. */ + transaction: OnyxEntry; }; -type IOURequestStepDescriptionProps = IOURequestStepDescriptionOnyxProps & - WithWritableReportOrNotFoundProps & { - /** Holds data related to Expense view state, rather than the underlying Expense data. */ - transaction: OnyxEntry; - }; - 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 @@ -98,7 +92,16 @@ function IOURequestStepDescription({ Navigation.goBack(backTo); }; + const updateDescriptionRef = (value: string) => { + descriptionRef.current = value; + }; + const updateComment = (value: FormOnyxValues) => { + if (!transaction?.transactionID) { + return; + } + + isSavedRef.current = true; const newComment = value.moneyRequestComment.trim(); // Only update comment if it has changed @@ -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); @@ -151,10 +152,12 @@ function IOURequestStepDescription({ > + { + if (isSavedRef.current) { + return false; + } + return descriptionRef.current !== currentDescription; + }} + /> ); } IOURequestStepDescription.displayName = 'IOURequestStepDescription'; -const IOURequestStepDescriptionWithOnyx = withOnyx({ - 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); diff --git a/src/pages/iou/request/step/IOURequestStepMerchant.tsx b/src/pages/iou/request/step/IOURequestStepMerchant.tsx index 80720b1cbf1c..ac6035725106 100644 --- a/src/pages/iou/request/step/IOURequestStepMerchant.tsx +++ b/src/pages/iou/request/step/IOURequestStepMerchant.tsx @@ -1,13 +1,13 @@ -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'; +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 Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; @@ -16,30 +16,15 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; 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'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; import withWritableReportOrNotFound from './withWritableReportOrNotFound'; -type IOURequestStepMerchantOnyxProps = { - /** The draft transaction that holds data to be persisted on the current transaction */ - splitDraftTransaction: OnyxEntry; - - /** The policy of the report */ - policy: OnyxEntry; - - /** Collection of categories attached to a policy */ - policyCategories: OnyxEntry; - - /** Collection of tags attached to a policy */ - policyTags: OnyxEntry; -}; - -type IOURequestStepMerchantProps = IOURequestStepMerchantOnyxProps & - WithWritableReportOrNotFoundProps & +type IOURequestStepMerchantProps = WithWritableReportOrNotFoundProps & WithFullTransactionOrNotFoundProps; function IOURequestStepMerchant({ @@ -47,12 +32,12 @@ function IOURequestStepMerchant({ params: {transactionID, reportID, backTo, action, iouType}, }, transaction, - splitDraftTransaction, - policy, - policyTags, - policyCategories, report, }: IOURequestStepMerchantProps) { + 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 styles = useThemeStyles(); const {translate} = useLocalize(); const {inputCallbackRef} = useAutoFocusInput(); @@ -62,6 +47,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); @@ -83,7 +71,12 @@ function IOURequestStepMerchant({ [isMerchantRequired, translate], ); + const updateMerchantRef = (value: string) => { + merchantRef.current = value; + }; + const updateMerchant = (value: FormOnyxValues) => { + isSavedRef.current = true; const newMerchant = value.moneyRequestMerchant?.trim(); // In the split flow, when editing we use SPLIT_TRANSACTION_DRAFT to save draft value @@ -124,10 +117,12 @@ function IOURequestStepMerchant({ > + { + if (isSavedRef.current) { + return false; + } + return merchantRef.current !== initialMerchant; + }} + /> ); } IOURequestStepMerchant.displayName = 'IOURequestStepMerchant'; -export default withWritableReportOrNotFound( - withFullTransactionOrNotFound( - withOnyx({ - 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'}`, - }, - })(IOURequestStepMerchant), - ), -); +export default withWritableReportOrNotFound(withFullTransactionOrNotFound(IOURequestStepMerchant));