Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -1,25 +1,20 @@
import {usePreventRemove} from '@react-navigation/native';
import type {NavigationAction} from '@react-navigation/native';
import {useIsFocused, usePreventRemove} from '@react-navigation/native';
import React, {memo, useCallback, useRef, useState} from 'react';
import ConfirmModal from '@components/ConfirmModal';
import useLocalize from '@hooks/useLocalize';
import navigationRef from '@libs/Navigation/navigationRef';
import type DiscardChangesConfirmationProps from './types';

function DiscardChangesConfirmation({getHasUnsavedChanges, isEnabled = true}: DiscardChangesConfirmationProps) {
function DiscardChangesConfirmation({hasUnsavedChanges}: DiscardChangesConfirmationProps) {
const {translate} = useLocalize();
const isFocused = useIsFocused();
const [isVisible, setIsVisible] = useState(false);
const shouldAllowNavigation = useRef(false);
const blockedNavigationAction = useRef<NavigationAction | undefined>(undefined);

const hasUnsavedChanges = isEnabled && isFocused && getHasUnsavedChanges();
const shouldPrevent = hasUnsavedChanges && !shouldAllowNavigation.current;

usePreventRemove(
shouldPrevent,
useCallback(({data}) => {
blockedNavigationAction.current = data.action;
hasUnsavedChanges,
useCallback((e) => {
blockedNavigationAction.current = e.data.action;
setIsVisible(true);
}, []),
);
Expand All @@ -34,7 +29,6 @@ function DiscardChangesConfirmation({getHasUnsavedChanges, isEnabled = true}: Di
cancelText={translate('common.cancel')}
onConfirm={() => {
setIsVisible(false);
shouldAllowNavigation.current = true;
if (blockedNavigationAction.current) {
navigationRef.current?.dispatch(blockedNavigationAction.current);
blockedNavigationAction.current = undefined;
Expand Down
62 changes: 19 additions & 43 deletions src/pages/iou/request/step/DiscardChangesConfirmation/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import type {NavigationAction} from '@react-navigation/native';
import {useIsFocused, useNavigation} from '@react-navigation/native';
import {useNavigation, usePreventRemove} from '@react-navigation/native';
import React, {memo, useCallback, useEffect, useRef, useState} from 'react';
import ConfirmModal from '@components/ConfirmModal';
import useBeforeRemove from '@hooks/useBeforeRemove';
import useLocalize from '@hooks/useLocalize';
import setNavigationActionToMicrotaskQueue from '@libs/Navigation/helpers/setNavigationActionToMicrotaskQueue';
import navigateAfterInteraction from '@libs/Navigation/navigateAfterInteraction';
Expand All @@ -11,29 +10,21 @@ import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNa
import type {RootNavigatorParamList} from '@libs/Navigation/types';
import type DiscardChangesConfirmationProps from './types';

function DiscardChangesConfirmation({getHasUnsavedChanges, onCancel, isEnabled = true}: DiscardChangesConfirmationProps) {
function DiscardChangesConfirmation({hasUnsavedChanges, onCancel}: DiscardChangesConfirmationProps) {
const navigation = useNavigation<PlatformStackNavigationProp<RootNavigatorParamList>>();
const isFocused = useIsFocused();
const {translate} = useLocalize();
const [isVisible, setIsVisible] = useState(false);
const blockedNavigationAction = useRef<NavigationAction>(undefined);
const shouldNavigateBack = useRef(false);
const [shouldNavigateBack, setShouldNavigateBack] = useState(false);
const isConfirmed = useRef(false);
const [discardConfirmed, setDiscardConfirmed] = useState(false);

useBeforeRemove(
useCallback(
(e) => {
if (!isEnabled || !isFocused || !getHasUnsavedChanges() || shouldNavigateBack.current) {
return;
}

e.preventDefault();
blockedNavigationAction.current = e.data.action;
navigateAfterInteraction(() => setIsVisible((prev) => !prev));
},
[getHasUnsavedChanges, isFocused, isEnabled],
),
isEnabled && isFocused,
usePreventRemove(
(hasUnsavedChanges || shouldNavigateBack) && !discardConfirmed,
useCallback((e) => {
blockedNavigationAction.current = e.data.action;
navigateAfterInteraction(() => setIsVisible(true));
}, []),
);

/**
Expand All @@ -42,48 +33,33 @@ function DiscardChangesConfirmation({getHasUnsavedChanges, onCancel, isEnabled =
* So we need to go forward to get back to the current page
*/
useEffect(() => {
if (!isEnabled || !isFocused) {
return undefined;
}
// transitionStart is triggered before the previous page is fully loaded so RHP sliding animation
// could be less "glitchy" when going back and forth between the previous and current pages
const unsubscribe = navigation.addListener('transitionStart', ({data: {closing}}) => {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
if (!getHasUnsavedChanges()) {
if (!hasUnsavedChanges || isConfirmed.current) {
return;
}
shouldNavigateBack.current = true;
setShouldNavigateBack(true);
if (closing) {
window.history.go(1);
return;
}
// Navigation.navigate() rerenders the current page and resets its states
window.history.go(1);
navigateAfterInteraction(() => setIsVisible((prev) => !prev));
navigateAfterInteraction(() => setIsVisible(true));
});

return unsubscribe;
}, [navigation, getHasUnsavedChanges, isFocused, isEnabled]);

useEffect(() => {
if ((isFocused && isEnabled) || !isVisible) {
return;
}
setIsVisible(false);
blockedNavigationAction.current = undefined;
shouldNavigateBack.current = false;
}, [isFocused, isVisible, isEnabled]);
}, [hasUnsavedChanges, navigation]);

const navigateBack = useCallback(() => {
if (blockedNavigationAction.current) {
navigationRef.current?.dispatch(blockedNavigationAction.current);
return;
}
if (!shouldNavigateBack.current) {
if (!shouldNavigateBack) {
return;
}
navigationRef.current?.goBack();
}, []);
}, [shouldNavigateBack]);

return (
<ConfirmModal
Expand All @@ -95,19 +71,19 @@ function DiscardChangesConfirmation({getHasUnsavedChanges, onCancel, isEnabled =
cancelText={translate('common.cancel')}
onConfirm={() => {
isConfirmed.current = true;
setDiscardConfirmed(true);
setIsVisible(false);
}}
onCancel={() => {
setIsVisible(false);
blockedNavigationAction.current = undefined;
shouldNavigateBack.current = false;
setShouldNavigateBack(false);
}}
onModalHide={() => {
if (isConfirmed.current) {
isConfirmed.current = false;
setNavigationActionToMicrotaskQueue(navigateBack);
} else {
shouldNavigateBack.current = false;
setShouldNavigateBack(false);
onCancel?.();
}
}}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
type DiscardChangesConfirmationProps = {
getHasUnsavedChanges: () => boolean;
onCancel?: () => void;
isEnabled?: boolean;
hasUnsavedChanges: boolean;
};

export default DiscardChangesConfirmationProps;
44 changes: 15 additions & 29 deletions 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, useEffect, useMemo, useRef, useState} from 'react';
import React, {useCallback, useMemo, useState} from 'react';
import {InteractionManager, View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import FormProvider from '@components/Form/FormProvider';
Expand Down Expand Up @@ -73,9 +73,7 @@ function IOURequestStepDescription({
return isEditingSplit && !lodashIsEmpty(splitDraftTransaction) ? (splitDraftTransaction?.comment?.comment ?? '') : (transaction?.comment?.comment ?? '');
}, [isTransactionDraft, iouType, isEditingSplit, splitDraftTransaction, transaction?.comment?.comment]);

const [currentDescription, setCurrentDescription] = useState(currentDescriptionInMarkdown);
const [isSaved, setIsSaved] = useState(false);
const shouldNavigateAfterSaveRef = useRef(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
useRestartOnReceiptFailure(transaction, reportID, iouType, action);
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
const currentUserAccountIDParam = currentUserPersonalDetails.accountID;
Expand Down Expand Up @@ -121,35 +119,31 @@ function IOURequestStepDescription({
Navigation.goBack(backTo);
}, [backTo]);

useEffect(() => {
if (!isSaved || !shouldNavigateAfterSaveRef.current) {
return;
}
shouldNavigateAfterSaveRef.current = false;
navigateBack();
}, [isSaved, navigateBack]);

const updateDescriptionRef = (value: string) => {
setCurrentDescription(value);
};
const updateHasUnsavedChanges = useCallback(
(value: string) => {
if (value === currentDescriptionInMarkdown) {
setHasUnsavedChanges(false);
return;
}
setHasUnsavedChanges(true);
},
[currentDescriptionInMarkdown],
);

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

setHasUnsavedChanges(false);
const newComment = value.moneyRequestComment.trim();

if (newComment === currentDescriptionInMarkdown) {
setIsSaved(true);
shouldNavigateAfterSaveRef.current = true;
return;
}

if (isEditingSplit) {
setDraftSplitTransaction(transaction?.transactionID, splitDraftTransaction, {comment: newComment});
setIsSaved(true);
shouldNavigateAfterSaveRef.current = true;
return;
}

Expand All @@ -170,9 +164,6 @@ function IOURequestStepDescription({
parentReportNextStep,
});
}

setIsSaved(true);
shouldNavigateAfterSaveRef.current = true;
};

// eslint-disable-next-line rulesdir/no-negated-variables
Expand Down Expand Up @@ -207,7 +198,7 @@ function IOURequestStepDescription({
inputID={INPUT_IDS.MONEY_REQUEST_COMMENT}
name={INPUT_IDS.MONEY_REQUEST_COMMENT}
defaultValue={currentDescriptionInMarkdown}
onValueChange={updateDescriptionRef}
onValueChange={updateHasUnsavedChanges}
label={translate('moneyRequestConfirmationList.whatsItFor')}
accessibilityLabel={translate('moneyRequestConfirmationList.whatsItFor')}
role={CONST.ROLE.PRESENTATION}
Expand All @@ -228,12 +219,7 @@ function IOURequestStepDescription({
inputRef.current?.focus();
});
}}
getHasUnsavedChanges={() => {
if (isSaved) {
return false;
}
return currentDescription !== currentDescriptionInMarkdown;
}}
hasUnsavedChanges={hasUnsavedChanges}
/>
</StepScreenWrapper>
);
Expand Down
50 changes: 18 additions & 32 deletions src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,16 +79,14 @@ function IOURequestStepDistanceOdometer({
// Key to force TextInput remount when resetting state after tab switch
const [inputKey, setInputKey] = useState<number>(0);

// Track initial values for DiscardChangesConfirmation
const initialStartReadingRef = useRef<string>('');
const initialEndReadingRef = useRef<string>('');
// Baseline readings for DiscardChangesConfirmation
const [initialReadings, setInitialReadings] = useState<{start: string; end: string}>({start: '', end: ''});
const [odometerImage, setOdometerImage] = useState<{start?: FileObject | string; end?: FileObject | string}>({});
const hasInitializedRefs = useRef(false);
// Track local state via refs to avoid including them in useEffect dependencies
const startReadingRef = useRef<string>('');
const endReadingRef = useRef<string>('');
const initialStartImageRef = useRef<FileObject | string | undefined>(undefined);
const initialEndImageRef = useRef<FileObject | string | undefined>(undefined);
const prevSelectedTabRef = useRef<string | undefined>(undefined);
// Compute local state presence
const hasLocalState = useMemo(() => !!startReading || !!endReading, [startReading, endReading]);

const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`);
const isArchived = isArchivedReport(reportNameValuePairs);
Expand Down Expand Up @@ -153,12 +151,8 @@ function IOURequestStepDistanceOdometer({
if (prevSelectedTab === CONST.TAB_REQUEST.DISTANCE_ODOMETER && selectedTab !== CONST.TAB_REQUEST.DISTANCE_ODOMETER) {
setStartReading('');
setEndReading('');
startReadingRef.current = '';
endReadingRef.current = '';
initialStartReadingRef.current = '';
initialEndReadingRef.current = '';
initialStartImageRef.current = undefined;
initialEndImageRef.current = undefined;
setInitialReadings({start: '', end: ''});
setOdometerImage({start: undefined, end: undefined});
setFormError('');
// Force TextInput remount to reset label position
setInputKey((prev) => prev + 1);
Expand All @@ -177,10 +171,8 @@ function IOURequestStepDistanceOdometer({
const currentEnd = currentTransaction?.comment?.odometerEnd;
const startValue = currentStart !== null && currentStart !== undefined ? currentStart.toString() : '';
const endValue = currentEnd !== null && currentEnd !== undefined ? currentEnd.toString() : '';
initialStartReadingRef.current = startValue;
initialEndReadingRef.current = endValue;
initialStartImageRef.current = currentTransaction?.comment?.odometerStartImage;
initialEndImageRef.current = currentTransaction?.comment?.odometerEndImage;
setInitialReadings({start: startValue, end: endValue});
setOdometerImage({start: currentTransaction?.comment?.odometerStartImage, end: currentTransaction?.comment?.odometerEndImage});
hasInitializedRefs.current = true;
}, [
currentTransaction?.comment?.odometerStart,
Expand All @@ -200,7 +192,6 @@ function IOURequestStepDistanceOdometer({
// 2. We're editing and transaction has data (to load existing values), OR
// 3. Transaction has data but local state is empty (user navigated back from another page)
const hasTransactionData = (currentStart !== null && currentStart !== undefined) || (currentEnd !== null && currentEnd !== undefined);
const hasLocalState = startReadingRef.current || endReadingRef.current;
const shouldInitialize =
(!hasInitializedRefs.current && hasTransactionData) ||
(isEditing && hasTransactionData && !hasLocalState) ||
Expand All @@ -213,11 +204,9 @@ function IOURequestStepDistanceOdometer({
if (startValue || endValue) {
setStartReading(startValue);
setEndReading(endValue);
startReadingRef.current = startValue;
endReadingRef.current = endValue;
}
}
}, [currentTransaction?.comment?.odometerStart, currentTransaction?.comment?.odometerEnd, isEditing]);
}, [currentTransaction?.comment?.odometerStart, currentTransaction?.comment?.odometerEnd, isEditing, hasLocalState]);

// Calculate total distance - updated live after every input change
const totalDistance = (() => {
Expand Down Expand Up @@ -316,7 +305,6 @@ function IOURequestStepDistanceOdometer({
return;
}
setStartReading(text);
startReadingRef.current = text;
if (formError) {
setFormError('');
}
Expand All @@ -327,7 +315,6 @@ function IOURequestStepDistanceOdometer({
return;
}
setEndReading(text);
endReadingRef.current = text;
if (formError) {
setFormError('');
}
Expand Down Expand Up @@ -507,6 +494,13 @@ function IOURequestStepDistanceOdometer({
navigateToNextPage();
};

const hasUnsavedChanges = useMemo(
() =>
shouldEnableDiscardConfirmation &&
(startReading !== initialReadings.start || endReading !== initialReadings.end || odometerImage.start !== odometerStartImage || odometerImage.end !== odometerEndImage),
[shouldEnableDiscardConfirmation, startReading, endReading, initialReadings.start, initialReadings.end, odometerImage.start, odometerImage.end, odometerStartImage, odometerEndImage],
);

return (
<StepScreenWrapper
headerTitle={translate('common.distance')}
Expand Down Expand Up @@ -646,15 +640,7 @@ function IOURequestStepDistanceOdometer({
/>
</View>
</View>
<DiscardChangesConfirmation
isEnabled={shouldEnableDiscardConfirmation}
getHasUnsavedChanges={() => {
const hasReadingChanges = startReadingRef.current !== initialStartReadingRef.current || endReadingRef.current !== initialEndReadingRef.current;
const hasImageChanges =
transaction?.comment?.odometerStartImage !== initialStartImageRef.current || transaction?.comment?.odometerEndImage !== initialEndImageRef.current;
return hasReadingChanges || hasImageChanges;
}}
/>
<DiscardChangesConfirmation hasUnsavedChanges={hasUnsavedChanges} />
</StepScreenWrapper>
);
}
Expand Down
Loading
Loading