Skip to content

Commit db26f8e

Browse files
authored
Merge pull request Expensify#85493 from mukhrr/fix/83781
Allowing distance to be edited while editing
2 parents fad099d + 222682e commit db26f8e

13 files changed

Lines changed: 811 additions & 182 deletions

src/CONST/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6303,6 +6303,7 @@ const CONST = {
63036303
RECEIPT_TAB_ID: 'ReceiptTab',
63046304
IOU_REQUEST_TYPE: 'iouRequestType',
63056305
DISTANCE_REQUEST_TYPE: 'distanceRequestType',
6306+
DISTANCE_EDIT_TYPE: 'distanceEditType',
63066307
SPLIT_EXPENSE_TAB_TYPE: 'splitExpenseTabType',
63076308
SPLIT: {
63086309
AMOUNT: 'amount',

src/components/ReportActionItem/ReportActionItemImage.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,13 @@ function ReportActionItemImage({
118118
const isMapDistanceRequest = !!transaction && isDistanceRequest(transaction) && !isManualDistanceRequest(transaction);
119119
const hasPendingWaypoints = transaction && isFetchingWaypointsFromServer(transaction);
120120
const hasErrors = !isEmptyObject(transaction?.errors) || !isEmptyObject(transaction?.errorFields?.route) || !isEmptyObject(transaction?.errorFields?.waypoints);
121-
const showMapAsImage = isMapDistanceRequest && (hasErrors || hasPendingWaypoints);
121+
// After a distance/rate edit the BE regenerates the receipt and invalidates the prior URL, but
122+
// the local `receipt.source` only refreshes when the Pusher push arrives. Render `ConfirmedRoute`
123+
// (which draws the map from `routes.coordinates`, independent of the URL) while any of these
124+
// edits are pending so the thumbnail doesn't briefly try to load the now-404'd URL.
125+
const pf = transaction?.pendingFields as Record<string, unknown> | undefined;
126+
const hasPendingReceiptRegeneration = !!pf && (!!pf.distance || !!pf.merchant || !!pf.customUnitRateID);
127+
const showMapAsImage = isMapDistanceRequest && (hasErrors || !!hasPendingWaypoints || hasPendingReceiptRegeneration);
122128

123129
if (showMapAsImage) {
124130
return (

src/libs/ReportUtils.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4589,7 +4589,6 @@ function getTransactionDetails(
45894589
}
45904590

45914591
const report = getReportOrDraftReport(transaction?.reportID, undefined, 'report' in transaction ? transaction.report : undefined);
4592-
const isManualDistanceRequest = isManualDistanceRequestTransactionUtils(transaction);
45934592
const isFromExpenseReport = (!isEmptyObject(report) && isExpenseReport(report)) || isPaidGroupPolicyPolicyUtils(policy);
45944593

45954594
return {
@@ -4616,7 +4615,7 @@ function getTransactionDetails(
46164615
convertedAmount: getConvertedAmount(transaction, isFromExpenseReport, transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID, allowNegativeAmount, disableOppositeConversion),
46174616
postedDate: getFormattedPostedDate(transaction),
46184617
transactionID: transaction.transactionID,
4619-
...(isManualDistanceRequest && {distance: transaction.comment?.customUnit?.quantity ?? undefined}),
4618+
...(isDistanceRequest(transaction) && {distance: transaction.comment?.customUnit?.quantity ?? undefined}),
46204619
};
46214620
}
46224621

src/libs/TransactionUtils/getDistanceInMeters.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,19 @@ import type {Unit} from '@src/types/onyx/Policy';
55
// Get the distance in meters from the transaction.
66
// This function is placed in a separate file to avoid circular dependencies.
77
function getDistanceInMeters(transaction: OnyxInputOrEntry<Transaction>, unit: Unit | undefined) {
8-
// If we are creating a new distance request, the distance is available in routes.route0.distance and it's already in meters.
9-
if (transaction?.routes?.route0?.distance) {
10-
return transaction.routes.route0.distance;
11-
}
12-
138
// If the request is completed, transaction.routes is cleared and comment.customUnit.quantity holds the new distance in the selected unit.
149
// We need to convert it from the selected distance unit to meters.
10+
// This check takes priority because after a manual distance edit, routes.route0.distance may still
11+
// hold a stale route-calculated value while quantity reflects the user's intended distance.
1512
if (transaction?.comment?.customUnit?.quantity && unit) {
1613
return DistanceRequestUtils.convertToDistanceInMeters(transaction.comment.customUnit.quantity, unit);
1714
}
15+
16+
// If we are creating a new distance request, the distance is available in routes.route0.distance and it's already in meters.
17+
if (transaction?.routes?.route0?.distance) {
18+
return transaction.routes.route0.distance;
19+
}
20+
1821
return 0;
1922
}
2023

src/libs/TransactionUtils/index.ts

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,17 @@ function hasGPSWaypoints(transaction: OnyxEntry<Transaction>) {
191191
return !!waypoint?.keyForList?.startsWith('gps');
192192
}
193193

194+
/**
195+
* Compare two waypoint collections by their addresses only (ignoring coordinates/names), which is
196+
* the meaningful signal for "did the user change the route?". Numeric fields like lat/lng can drift
197+
* due to rounding in transaction backups, so they're excluded.
198+
*/
199+
function haveWaypointAddressesChanged(oldWaypoints: WaypointCollection | undefined, newWaypoints: WaypointCollection | undefined): boolean {
200+
const toAddresses = (collection: WaypointCollection | undefined) =>
201+
Object.fromEntries(Object.entries(collection ?? {}).map(([key, waypoint]) => [key, waypoint && 'address' in waypoint ? waypoint.address : undefined]));
202+
return !deepEqual(toAddresses(oldWaypoints), toAddresses(newWaypoints));
203+
}
204+
194205
function isMapDistanceRequest(transaction: OnyxEntry<Transaction>): boolean {
195206
// This is used during the expense creation flow before the transaction has been saved to the server
196207
if (transaction && Object.hasOwn(transaction, 'iouRequestType')) {
@@ -719,16 +730,22 @@ function getUpdatedTransaction({
719730
}
720731
shouldStopSmartscan = true;
721732

722-
if (!transactionChanges.routes?.route0?.geometry?.coordinates) {
733+
// A manual-distance edit re-sends unchanged waypoints; when they truly didn't change, leave
734+
// `amount`/`modifiedAmount` to the sibling `distance` branch instead of zeroing them here.
735+
const waypointsActuallyChanged = !deepEqual(transactionChanges.waypoints, transaction?.comment?.waypoints);
736+
737+
if (waypointsActuallyChanged && !transactionChanges.routes?.route0?.geometry?.coordinates) {
723738
// The waypoints were changed, but there is no route – it is pending from the BE and we should mark the fields as pending
724739
updatedTransaction.amount = CONST.IOU.DEFAULT_AMOUNT;
725740
updatedTransaction.modifiedAmount = CONST.IOU.DEFAULT_AMOUNT;
726741
updatedTransaction.modifiedMerchant = translateLocal('iou.fieldPending');
727-
} else {
742+
} else if (transactionChanges.routes?.route0?.geometry?.coordinates) {
728743
const mileageRate = DistanceRequestUtils.getRate({transaction: updatedTransaction, policy});
729744
const {unit, rate} = mileageRate;
730745

731-
const distanceInMeters = getDistanceInMeters(transaction, unit);
746+
// Use route distance directly since waypoints changed and the route was recalculated.
747+
// getDistanceInMeters prefers quantity which may hold a stale manually-edited value.
748+
const distanceInMeters = transactionChanges.routes?.route0?.distance ?? getDistanceInMeters(transaction, unit);
732749
const amount = DistanceRequestUtils.getDistanceRequestAmount(distanceInMeters, unit, rate ?? 0);
733750
const updatedAmount = isFromExpenseReport || isUnReportedExpense ? -amount : amount;
734751
const updatedMerchant = DistanceRequestUtils.getDistanceMerchant(
@@ -746,6 +763,13 @@ function getUpdatedTransaction({
746763
updatedTransaction.amount = updatedAmount;
747764
updatedTransaction.modifiedAmount = updatedAmount;
748765
updatedTransaction.modifiedMerchant = updatedMerchant;
766+
767+
// Sync `customUnit.quantity` to the new route distance. Without this the prior manual
768+
// quantity (set when the user edited distance manually before changing waypoints) would
769+
// linger and drive `getDistanceInMeters`, since that helper prefers quantity over routes.
770+
if (unit) {
771+
lodashSet(updatedTransaction, 'comment.customUnit.quantity', roundToTwoDecimalPlaces(DistanceRequestUtils.convertDistanceUnit(distanceInMeters, unit)));
772+
}
749773
}
750774
}
751775

@@ -861,8 +885,11 @@ function getUpdatedTransaction({
861885

862886
if (Object.hasOwn(transactionChanges, 'distance') && typeof transactionChanges.distance === 'number') {
863887
const distance = roundToTwoDecimalPlaces(transactionChanges.distance ?? 0);
888+
// Capture before mutating quantity below; needed by the fallback amount computation.
889+
const previousDistanceInMeters = getDistanceInMeters(transaction, transaction?.comment?.customUnit?.distanceUnit);
864890

865891
lodashSet(updatedTransaction, 'comment.customUnit.quantity', distance);
892+
lodashSet(updatedTransaction, 'routes.route0.distance', null);
866893
shouldStopSmartscan = true;
867894

868895
const updatedMileageRate = DistanceRequestUtils.getRate({transaction: updatedTransaction, policy, useTransactionDistanceUnit: false});
@@ -884,9 +911,20 @@ function getUpdatedTransaction({
884911
isManualDistanceRequest(transaction),
885912
);
886913

887-
updatedTransaction.modifiedAmount = amount;
888-
updatedTransaction.modifiedMerchant = updatedMerchant;
889-
updatedTransaction.modifiedCurrency = updatedCurrency;
914+
// No locally resolvable rate (e.g. track expense without policy loaded) → scale the previous
915+
// amount by the distance ratio so the optimistic value isn't 0. `modifiedAmount` is `""` for
916+
// unedited transactions, so coerce via Number() and fall through to `amount`.
917+
const previousAmount = Number(transaction?.modifiedAmount) || transaction?.amount || 0;
918+
const useFallback = !rate && !!previousDistanceInMeters && !!previousAmount && !!distanceInMeters;
919+
if (useFallback) {
920+
updatedTransaction.modifiedAmount = Math.round(previousAmount * (distanceInMeters / previousDistanceInMeters));
921+
updatedTransaction.modifiedMerchant = updatedMerchant;
922+
// Leave currency alone — without a resolvable rate we don't know the target currency.
923+
} else {
924+
updatedTransaction.modifiedAmount = amount;
925+
updatedTransaction.modifiedMerchant = updatedMerchant;
926+
updatedTransaction.modifiedCurrency = updatedCurrency;
927+
}
890928
}
891929

892930
if (Object.hasOwn(transactionChanges, 'odometerStart') && typeof transactionChanges.odometerStart === 'number') {
@@ -2881,6 +2919,7 @@ export {
28812919
isReceiptBeingScanned,
28822920
didReceiptScanSucceed,
28832921
getValidWaypoints,
2922+
haveWaypointAddressesChanged,
28842923
isDistanceRequest,
28852924
isMapDistanceRequest,
28862925
isGPSDistanceRequest,

src/libs/actions/IOU/UpdateMoneyRequest.ts

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
getClearedPendingFields,
2929
getMerchant,
3030
getUpdatedTransaction,
31+
haveWaypointAddressesChanged,
3132
isDistanceRequest as isDistanceRequestTransactionUtils,
3233
isFetchingWaypointsFromServer,
3334
isOnHold,
@@ -530,7 +531,10 @@ function updateMoneyRequestDistance({
530531
// Don't sanitize waypoints here - keep all fields for Onyx optimistic data (e.g., keyForList)
531532
// Sanitization happens when building API params
532533
...(waypoints && {waypoints}),
533-
routes,
534+
// Only include routes when the caller explicitly provided them. Including `routes: undefined`
535+
// would make the optimistic merge wipe the existing route, briefly blanking the map thumbnail
536+
// and report preview before the server response restores it.
537+
...(routes !== undefined && {routes}),
534538
...(distance && {distance}),
535539
...(odometerStart !== undefined && {odometerStart}),
536540
...(odometerEnd !== undefined && {odometerEnd}),
@@ -975,20 +979,46 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U
975979
>
976980
> = [];
977981

978-
// Step 1: Set any "pending fields" (ones updated while the user was offline) to have error messages in the failureData
979-
const pendingFields: OnyxTypes.Transaction['pendingFields'] = Object.fromEntries(Object.keys(transactionChanges).map((key) => [key, CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE]));
982+
// Step 1: Get the transaction being updated
983+
const transaction = getAllTransactions()?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];
984+
985+
// The manual-distance submit path always sends waypoints to keep the BE in sync, even when the user
986+
// only edited the distance number. Detect whether the addresses actually changed so we can skip the
987+
// optimistic side effects (pending field, route clearing, render-path swap to interactive map) that
988+
// would otherwise make the parent map briefly disappear on a pure distance edit.
989+
const hasWaypointAddressesChanged = 'waypoints' in transactionChanges && haveWaypointAddressesChanged(transaction?.comment?.waypoints, transactionChanges.waypoints);
990+
const shouldSuppressWaypointsAsPending = 'waypoints' in transactionChanges && !hasWaypointAddressesChanged;
991+
992+
// Step 2: Set any "pending fields" (ones updated while the user was offline) to have error messages in the failureData
993+
const pendingFields: OnyxTypes.Transaction['pendingFields'] = Object.fromEntries(
994+
Object.keys(transactionChanges)
995+
.filter((key) => !(shouldSuppressWaypointsAsPending && key === 'waypoints'))
996+
.map((key) => [key, CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE]),
997+
);
998+
// Flag `merchant` as pending on any edit that causes the BE to regenerate the receipt
999+
// (waypoints / distance / rate). `merchant` isn't in `transactionChanges`, so the success-data
1000+
// merge won't clear it via `clearedPendingFields` — it persists through the gap between API ack
1001+
// and the Pusher push that delivers the new `receipt.source`. The Pusher push then clears all
1002+
// pendingFields atomically together with the new URL, eliminating the broken-image flash.
1003+
// It also drives the Distance row's offline-feedback strikethrough for pure distance edits.
1004+
if ('waypoints' in transactionChanges || 'distance' in transactionChanges || 'customUnitRateID' in transactionChanges) {
1005+
pendingFields.merchant = CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE;
1006+
}
9801007
const clearedPendingFields = getClearedPendingFields(transactionChanges);
9811008
const errorFields = Object.fromEntries(Object.keys(pendingFields).map((key) => [key, getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage')]));
9821009

983-
// Step 2: Get all the collections being updated
984-
const transaction = getAllTransactions()?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];
985-
9861010
const isTransactionOnHold = isOnHold(transaction);
9871011
const isFromExpenseReport = isExpenseReport(iouReport) || isInvoiceReportReportUtils(iouReport);
1012+
// Drop the waypoints from the changes fed to getUpdatedTransaction when they didn't actually change,
1013+
// so we skip the waypoints branch that flips isLoading and clobbers amount/merchant before being
1014+
// re-overridden by the distance branch.
1015+
const transactionChangesForOptimisticMerge: TransactionChanges = shouldSuppressWaypointsAsPending
1016+
? Object.fromEntries(Object.entries(transactionChanges).filter(([key]) => key !== 'waypoints'))
1017+
: transactionChanges;
9881018
const updatedTransaction: OnyxEntry<OnyxTypes.Transaction> = transaction
9891019
? getUpdatedTransaction({
9901020
transaction,
991-
transactionChanges,
1021+
transactionChanges: transactionChangesForOptimisticMerge,
9921022
isFromExpenseReport,
9931023
isSplitTransaction,
9941024
policy,
@@ -1003,6 +1033,12 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U
10031033

10041034
const dataToIncludeInParams: Partial<TransactionDetails> = Object.fromEntries(Object.entries(transactionDetails ?? {}).filter(([key]) => key in transactionChanges));
10051035

1036+
// Preserve the caller's full-precision distance so the server doesn't fire `increasedDistance`
1037+
// when the rounded display value drifts above the exact route distance.
1038+
if ('distance' in transactionChanges && typeof transactionChanges.distance === 'number') {
1039+
dataToIncludeInParams.distance = transactionChanges.distance;
1040+
}
1041+
10061042
const apiParams: UpdateMoneyRequestParams = {
10071043
...dataToIncludeInParams,
10081044
reportID: iouReport?.reportID,
@@ -1017,6 +1053,9 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U
10171053
// For split transactions, the merchant and amount are already computed in transactionChanges,
10181054
// so we can build a valid optimistic MODIFIED_EXPENSE even when waypoints are pending.
10191055
const hasSplitDistanceMessageFields = !!isSplitTransaction && hasModifiedMerchant && hasModifiedAmount;
1056+
// When distance is provided alongside waypoints (route was already calculated), we have valid
1057+
// merchant/amount data to build the optimistic report action instead of waiting for the server.
1058+
const hasDistanceWithWaypoints = hasPendingWaypoints && 'distance' in transactionChanges;
10201059
if (transaction && updatedTransaction && (hasPendingWaypoints || hasModifiedDistanceRate)) {
10211060
// Delete the draft transaction when editing waypoints when the server responds successfully and there are no errors
10221061
successData.push({
@@ -1052,7 +1091,11 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U
10521091
const updatedReportAction = shouldBuildOptimisticModifiedExpenseReportAction
10531092
? buildOptimisticModifiedExpenseReportAction(transactionThreadReport, transaction, transactionChanges, isFromExpenseReport, policy, updatedTransaction, allowNegative)
10541093
: null;
1055-
if ((!hasPendingWaypoints || hasSplitDistanceMessageFields) && !(hasModifiedDistanceRate && isFetchingWaypointsFromServer(transaction)) && updatedReportAction) {
1094+
if (
1095+
(!hasPendingWaypoints || hasSplitDistanceMessageFields || hasDistanceWithWaypoints) &&
1096+
!(hasModifiedDistanceRate && isFetchingWaypointsFromServer(transaction)) &&
1097+
updatedReportAction
1098+
) {
10561099
apiParams.reportActionID = updatedReportAction.reportActionID;
10571100

10581101
optimisticData.push({
@@ -1264,15 +1307,22 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U
12641307
apiParams.attendees = JSON.stringify(apiParams?.attendees);
12651308
}
12661309

1267-
// Clear out the error fields and loading states on success
1310+
// Clear out the error fields and loading states on success.
1311+
// Only clear `routes` when waypoints/rate changed (the server will push a fresh route via Pusher).
1312+
// For pure distance edits the route is unchanged, and clearing it would make the map briefly disappear.
1313+
// When the caller already supplied a valid optimistic route (waypoint edit with route pre-fetched
1314+
// locally), keep it so the receipt thumbnail and ConfirmedRoute don't flicker between success and
1315+
// the Pusher route push.
1316+
const hasValidOptimisticRoute = !!transactionChanges.routes?.route0?.geometry?.coordinates?.length;
1317+
const shouldClearRoutes = (hasWaypointAddressesChanged || hasModifiedDistanceRate) && !hasValidOptimisticRoute;
12681318
successData.push({
12691319
onyxMethod: Onyx.METHOD.MERGE,
12701320
key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
12711321
value: {
12721322
pendingFields: clearedPendingFields,
12731323
isLoading: false,
12741324
errorFields: null,
1275-
routes: null,
1325+
...(shouldClearRoutes && {routes: null}),
12761326
},
12771327
});
12781328

@@ -1341,9 +1391,13 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U
13411391
if (hasPendingWaypoints) {
13421392
optimisticViolations = optimisticViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.NO_ROUTE);
13431393
}
1344-
if (hasModifiedDistanceRate || hasModifiedDistance) {
1394+
if (hasModifiedDistanceRate || hasModifiedDistance || hasPendingWaypoints) {
1395+
// Clear stale distance-related violations while the server reprocesses.
1396+
// The server will re-evaluate and re-add any that legitimately apply.
13451397
optimisticViolations = optimisticViolations.filter(
1346-
(violation) => !(violation.name === CONST.VIOLATIONS.MODIFIED_AMOUNT && violation.data?.type === CONST.MODIFIED_AMOUNT_VIOLATION_DATA.DISTANCE),
1398+
(violation) =>
1399+
!(violation.name === CONST.VIOLATIONS.MODIFIED_AMOUNT && violation.data?.type === CONST.MODIFIED_AMOUNT_VIOLATION_DATA.DISTANCE) &&
1400+
violation.name !== CONST.VIOLATIONS.INCREASED_DISTANCE,
13471401
);
13481402
}
13491403

0 commit comments

Comments
 (0)