diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 088425276122..3822fd4540ee 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -147,7 +147,7 @@ "../../src/components/DatePicker/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/DialogLabelContext.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/DisplayNames/DisplayNamesWithTooltip.tsx" "react-hooks/refs" 6 -"../../src/components/DistanceRequest/DistanceRequestFooter.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/DistanceRequest/DistanceRequestFooter.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/DistanceRequest/DistanceRequestFooter.tsx" "no-restricted-imports" 1 "../../src/components/DistanceRequest/DistanceRequestRenderItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Domain/CopyableTextField.tsx" "no-restricted-imports" 1 diff --git a/package-lock.json b/package-lock.json index 2ab3ed072a3b..c6ae17604e28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -203,6 +203,7 @@ "@types/canvas-size": "^1.2.2", "@types/concurrently": "^7.0.3", "@types/d3-scale": "^4.0.9", + "@types/geojson": "^7946.0.16", "@types/howler": "^2.2.12", "@types/jest": "^29.5.14", "@types/jest-when": "^3.5.2", @@ -11032,12 +11033,6 @@ "pbf": "^4.0.2" } }, - "node_modules/@mapbox/vector-tile/node_modules/@types/geojson": { - "version": "7946.0.16", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", - "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", - "license": "MIT" - }, "node_modules/@maplibre/maplibre-gl-style-spec": { "version": "19.3.3", "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-19.3.3.tgz", @@ -18045,7 +18040,9 @@ "license": "MIT" }, "node_modules/@types/geojson": { - "version": "7946.0.8", + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", "license": "MIT" }, "node_modules/@types/geojson-vt": { @@ -27025,6 +27022,12 @@ "url": "https://opencollective.com/turf" } }, + "node_modules/geojson-rbush/node_modules/@types/geojson": { + "version": "7946.0.8", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz", + "integrity": "sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA==", + "license": "MIT" + }, "node_modules/geojson-vt": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.3.tgz", @@ -32684,12 +32687,6 @@ "tinyqueue": "^3.0.0" } }, - "node_modules/mapbox-gl/node_modules/@types/geojson": { - "version": "7946.0.16", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", - "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", - "license": "MIT" - }, "node_modules/mapbox-gl/node_modules/quickselect": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", diff --git a/package.json b/package.json index 96c08d4e24ae..4c91602c55e4 100644 --- a/package.json +++ b/package.json @@ -277,6 +277,7 @@ "@types/canvas-size": "^1.2.2", "@types/concurrently": "^7.0.3", "@types/d3-scale": "^4.0.9", + "@types/geojson": "^7946.0.16", "@types/howler": "^2.2.12", "@types/jest": "^29.5.14", "@types/jest-when": "^3.5.2", diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 99e3c90c0037..2e060d2e4e24 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2436,6 +2436,8 @@ const CONST = { TRANSACTION: { RESULTS_PAGE_SIZE: 20, DEFAULT_MERCHANT: 'Expense', + DEFAULT_ROUTE_KEY: 'route0', + ALTERNATE_ROUTE_KEY: 'route1', UNKNOWN_MERCHANT: 'Unknown Merchant', PARTIAL_TRANSACTION_MERCHANT: '(none)', TYPE: { @@ -9333,6 +9335,14 @@ const CONST = { WAYPOINTS: 'waypoints', }, + ALTERNATE_DIRECTIONS_MAP_VIEW_LAYERS: { + SOURCE: 'alternate-directions-route-source', + UNSELECTED_FILL: 'alternate-directions-unselected-route-fill', + UNSELECTED_BORDER: 'alternate-directions-unselected-route-border', + SELECTED_FILL: 'alternate-directions-selected-route-fill', + SELECTED_BORDER: 'alternate-directions-selected-route-border', + }, + MAP_CURRENT_LOCATION_FILL_COLOR: '#0185FF', PARTNER_ID: { diff --git a/src/components/ConfirmedRoute.tsx b/src/components/ConfirmedRoute.tsx index 392146c6e135..e2c482371705 100644 --- a/src/components/ConfirmedRoute.tsx +++ b/src/components/ConfirmedRoute.tsx @@ -5,7 +5,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import getArrayDepth from '@libs/getArrayDepth'; -import {getWaypointIndex} from '@libs/TransactionUtils'; +import {getSelectedRouteKey, getWaypointIndex} from '@libs/TransactionUtils'; import {init as initMapboxToken, stop as stopMapboxToken} from '@userActions/MapboxToken'; @@ -45,7 +45,8 @@ type ConfirmedRouteProps = { function ConfirmedRoute({transaction, isSmallerIcon, shouldHaveBorderRadius = true, requireRouteToDisplayMap = false, interactive, shouldDisplayCompass = true}: ConfirmedRouteProps) { const {isOffline} = useNetwork(); - const {route0: route} = transaction?.routes ?? {}; + const selectedRouteKey = getSelectedRouteKey(transaction); + const route = transaction?.routes?.[selectedRouteKey] ?? transaction?.routes?.[CONST.TRANSACTION.DEFAULT_ROUTE_KEY]; const waypoints = transaction?.comment?.waypoints ?? {}; const coordinates = route?.geometry?.coordinates ?? []; const styles = useThemeStyles(); diff --git a/src/components/DistanceRequest/DistanceRequestFooter.tsx b/src/components/DistanceRequest/DistanceRequestFooter.tsx index 85cc1f6cfcfb..c2d0b11f26be 100644 --- a/src/components/DistanceRequest/DistanceRequestFooter.tsx +++ b/src/components/DistanceRequest/DistanceRequestFooter.tsx @@ -9,14 +9,16 @@ import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; +import {setSelectedRoute} from '@libs/actions/Transaction'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; -import {getDistanceInMeters, getWaypointIndex, isCustomUnitRateIDForP2P} from '@libs/TransactionUtils'; +import {getSelectedRouteKey, getWaypointIndex, isCustomUnitRateIDForP2P} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy} from '@src/types/onyx'; import type {WaypointCollection} from '@src/types/onyx/Transaction'; import type Transaction from '@src/types/onyx/Transaction'; +import type TransactionState from '@src/types/utils/TransactionStateType'; import type {StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -41,9 +43,12 @@ type DistanceRequestFooterProps = { /** Optional style for the map container */ mapContainerStyle?: StyleProp; + + /** The state of the transaction (draft, current, etc.) used to persist route selection to the correct Onyx key */ + transactionState: TransactionState; }; -function DistanceRequestFooter({waypoints, transaction, navigateToWaypointEditPage, policy, mapContainerStyle}: DistanceRequestFooterProps) { +function DistanceRequestFooter({waypoints, transaction, navigateToWaypointEditPage, policy, mapContainerStyle, transactionState}: DistanceRequestFooterProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Plus']); @@ -60,6 +65,21 @@ function DistanceRequestFooter({waypoints, transaction, navigateToWaypointEditPa const policyCurrency = (policy ?? activePolicy ?? personalPolicy)?.outputCurrency ?? CONST.CURRENCY.USD; const mileageRate = isCustomUnitRateIDForP2P(transaction) ? DistanceRequestUtils.getRateForP2P(policyCurrency, transaction) : defaultMileageRate; const {unit} = mileageRate ?? {}; + const primaryRoute = transaction?.routes?.[CONST.TRANSACTION.DEFAULT_ROUTE_KEY]; + const alternateRoute = transaction?.routes?.[CONST.TRANSACTION.ALTERNATE_ROUTE_KEY]; + const isAlternateDirectionSelected = getSelectedRouteKey(transaction) === CONST.TRANSACTION.ALTERNATE_ROUTE_KEY; + const handleRouteSelection = (isAlternate: boolean) => { + if (isAlternate === isAlternateDirectionSelected) { + return; + } + setSelectedRoute( + transaction?.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID, + isAlternate ? CONST.TRANSACTION.ALTERNATE_ROUTE_KEY : CONST.TRANSACTION.DEFAULT_ROUTE_KEY, + (isAlternate ? alternateRoute : primaryRoute)?.distance ?? undefined, + DistanceRequestUtils.getDistanceUnit(transaction, mileageRate), + transactionState, + ); + }; const waypointMarkers: WayPoint[] = []; for (const [key, waypoint] of Object.entries(waypoints ?? {})) { @@ -105,12 +125,22 @@ function DistanceRequestFooter({waypoints, transaction, navigateToWaypointEditPa zoom: CONST.MAPBOX.DEFAULT_ZOOM, location: waypointMarkers?.at(0)?.coordinate ?? CONST.MAPBOX.DEFAULT_COORDINATE, }} - directionCoordinates={(transaction?.routes?.route0?.geometry?.coordinates as Array<[number, number]>) ?? []} + directionCoordinates={(primaryRoute?.geometry?.coordinates as Array<[number, number]>) ?? []} + alternateDirection={ + alternateRoute?.geometry?.coordinates && alternateRoute?.distance + ? { + coordinates: (alternateRoute.geometry.coordinates as Array<[number, number]>) ?? [], + distanceInMeters: alternateRoute.distance, + isSelected: isAlternateDirectionSelected, + } + : undefined + } + setIsAlternateDirectionSelected={handleRouteSelection} style={[styles.mapView, styles.mapEditView]} waypoints={waypointMarkers} styleURL={CONST.MAPBOX.STYLE_URL} overlayStyle={styles.mapEditView} - distanceInMeters={getDistanceInMeters(transaction, undefined)} + distanceInMeters={primaryRoute?.distance !== null ? primaryRoute?.distance : undefined} unit={unit} /> diff --git a/src/components/MapView/AlternateDirections.tsx b/src/components/MapView/AlternateDirections.tsx new file mode 100644 index 000000000000..8c8afe8208c3 --- /dev/null +++ b/src/components/MapView/AlternateDirections.tsx @@ -0,0 +1,51 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import Mapbox from '@rnmapbox/maps'; + +import type {AlternateDirectionsProps} from './MapViewTypes'; + +import {getAlternateDirectionsShape, SELECTED_BORDER_ID, SELECTED_FILL_ID, SOURCE_ID, UNSELECTED_BORDER_ID, UNSELECTED_FILL_ID} from './alternateDirectionsUtils'; + +function AlternateDirections({directionCoordinates, alternateDirection, setIsAlternateDirectionSelected}: AlternateDirectionsProps) { + const styles = useThemeStyles(); + const directionShape = getAlternateDirectionsShape(directionCoordinates, alternateDirection); + + return ( + { + const properties = features.at(0)?.properties; + if (typeof properties?.isAlternate !== 'boolean') { + return; + } + setIsAlternateDirectionSelected?.(properties.isAlternate); + }} + > + + + + + + ); +} + +export default AlternateDirections; diff --git a/src/components/MapView/AlternateDirections.web.tsx b/src/components/MapView/AlternateDirections.web.tsx new file mode 100644 index 000000000000..13650ce71a31 --- /dev/null +++ b/src/components/MapView/AlternateDirections.web.tsx @@ -0,0 +1,72 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {FilterSpecification} from 'mapbox-gl'; + +import React from 'react'; +import {Layer, Source} from 'react-map-gl/mapbox'; + +import type {AlternateDirectionsProps} from './MapViewTypes'; + +import {getAlternateDirectionsShape, SELECTED_BORDER_ID, SELECTED_FILL_ID, SOURCE_ID, UNSELECTED_BORDER_ID, UNSELECTED_FILL_ID} from './alternateDirectionsUtils'; + +/** Layers that must be interactive on the map so that tapping a route selects it. */ +const ALTERNATE_DIRECTIONS_LAYER_IDS = [UNSELECTED_BORDER_ID, UNSELECTED_FILL_ID, SELECTED_BORDER_ID, SELECTED_FILL_ID]; + +const UNSELECTED_FILTER: FilterSpecification = ['==', ['get', 'isSelected'], false]; +const SELECTED_FILTER: FilterSpecification = ['==', ['get', 'isSelected'], true]; + +function AlternateDirections({directionCoordinates, alternateDirection}: AlternateDirectionsProps) { + const styles = useThemeStyles(); + const layerLayoutStyle: Record = styles.mapDirectionLayer.layout; + const layerPaintStyle: Record = styles.mapDirectionLayer.paint; + const alternativeLayerLayoutStyle: Record = styles.alternativeMapDirectionLayer.layout; + const alternativeLayerPaintStyle: Record = styles.alternativeMapDirectionLayer.paint; + const layerBorderLayoutStyle: Record = styles.mapDirectionLayerBorder.layout; + const layerBorderPaintStyle: Record = styles.mapDirectionLayerBorder.paint; + + const directionShape = getAlternateDirectionsShape(directionCoordinates, alternateDirection); + + return ( + + + + + + + ); +} + +export default AlternateDirections; +export {ALTERNATE_DIRECTIONS_LAYER_IDS}; diff --git a/src/components/MapView/Directions.tsx b/src/components/MapView/Directions.tsx new file mode 100644 index 000000000000..467a6d787d8b --- /dev/null +++ b/src/components/MapView/Directions.tsx @@ -0,0 +1,56 @@ +import type {DirectionsProps} from './MapViewTypes'; + +import AlternateDirections from './AlternateDirections'; +import Direction from './Direction'; +import DistanceSymbol from './DistanceSymbol'; +import useDistanceUnit from './useDistanceUnit'; +import utils from './utils'; + +function Directions({directionCoordinates, alternateDirection, setIsAlternateDirectionSelected, distanceInMeters, unit, waypoints}: DirectionsProps) { + // Held here rather than in each DistanceSymbol so that toggling one label switches the unit for every label on the map. + const {distanceUnit, toggleDistanceUnit} = useDistanceUnit(unit); + + if (!directionCoordinates) { + return null; + } + + const alternateDirectionCoordinates = alternateDirection?.coordinates; + const hasAlternateDirection = !!alternateDirection && !!alternateDirectionCoordinates?.length; + const isAlternateDirectionSelected = !!alternateDirection?.isSelected; + + return ( + <> + {hasAlternateDirection ? ( + <> + + setIsAlternateDirectionSelected?.(true)} + /> + + ) : ( + + )} + setIsAlternateDirectionSelected?.(false)} + /> + + ); +} + +export default Directions; diff --git a/src/components/MapView/DistanceSymbol.tsx b/src/components/MapView/DistanceSymbol.tsx new file mode 100644 index 000000000000..93d435279787 --- /dev/null +++ b/src/components/MapView/DistanceSymbol.tsx @@ -0,0 +1,61 @@ +import Text from '@components/Text'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; + +import CONST from '@src/CONST'; + +import {View} from 'react-native'; + +import type {DistanceSymbolProps} from './MapViewTypes'; + +import DistanceSymbolMarker from './DistanceSymbolMarker'; +import utils from './utils'; + +function DistanceSymbol({distanceInMeters, distanceUnit, toggleDistanceUnit, directionCoordinates, waypoints, isSelected = true, selectDirection}: DistanceSymbolProps) { + const styles = useThemeStyles(); + + const distanceLabelText = DistanceRequestUtils.getDistanceForDisplayLabel(distanceInMeters ?? 0, distanceUnit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); + + const getDistanceSymbolCoordinate = () => { + if (!directionCoordinates?.length || !waypoints?.length) { + return; + } + const {northEast, southWest} = utils.getBounds( + waypoints.map((waypoint) => waypoint.coordinate), + directionCoordinates, + ); + const boundsCenter = utils.getBoundsCenter({northEast, southWest}); + + return utils.findClosestCoordinateOnLineFromCenter(boundsCenter, directionCoordinates); + }; + + const distanceSymbolCoordinate = getDistanceSymbolCoordinate(); + + if (!distanceInMeters || !distanceUnit || !distanceSymbolCoordinate) { + return null; + } + + // Pressing a symbol of an unselected direction selects that direction instead of toggling the unit. + const onPress = () => { + if (!isSelected && selectDirection) { + selectDirection(); + return; + } + toggleDistanceUnit(); + }; + + return ( + + + {distanceLabelText} + + + ); +} + +export default DistanceSymbol; diff --git a/src/components/MapView/DistanceSymbolMarker.tsx b/src/components/MapView/DistanceSymbolMarker.tsx new file mode 100644 index 000000000000..3cc53113e703 --- /dev/null +++ b/src/components/MapView/DistanceSymbolMarker.tsx @@ -0,0 +1,34 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; + +import {MarkerView} from '@rnmapbox/maps'; +import React from 'react'; +import {View} from 'react-native'; + +import type {DistanceSymbolMarkerProps} from './MapViewTypes'; + +import ToggleDistanceUnitButton from './ToggleDistanceUnitButton'; + +function DistanceSymbolMarker({distanceSymbolCoordinate, children, onPress}: DistanceSymbolMarkerProps) { + const styles = useThemeStyles(); + + return ( + + + + {children} + + + + ); +} + +export default DistanceSymbolMarker; diff --git a/src/components/MapView/DistanceSymbolMarker.web.tsx b/src/components/MapView/DistanceSymbolMarker.web.tsx new file mode 100644 index 000000000000..44db44080279 --- /dev/null +++ b/src/components/MapView/DistanceSymbolMarker.web.tsx @@ -0,0 +1,28 @@ +import {PressableWithoutFeedback} from '@components/Pressable'; + +import CONST from '@src/CONST'; + +import React from 'react'; +import {Marker} from 'react-map-gl/mapbox'; + +import type {DistanceSymbolMarkerProps} from './MapViewTypes'; + +function DistanceSymbolMarker({distanceSymbolCoordinate, children, onPress}: DistanceSymbolMarkerProps) { + return ( + + + {children} + + + ); +} + +export default DistanceSymbolMarker; diff --git a/src/components/MapView/GPSMapView.tsx b/src/components/MapView/GPSMapView.tsx index 950bf3932161..566a1a5c768c 100644 --- a/src/components/MapView/GPSMapView.tsx +++ b/src/components/MapView/GPSMapView.tsx @@ -41,7 +41,7 @@ const LOCATION_PUCK_PULSING = { const CURRENT_LOCATION_PUCK_IMAGE = 'current-location-puck-image'; function GPSMapView({accessToken, style, mapPadding, styleURL, pitchEnabled, waypoints, directionCoordinates: directionCoordinatesProp, isTrackingGPS}: GPSMapViewProps) { - const directionCoordinates = !directionCoordinatesProp || utils.isSingleSegmentRoute(directionCoordinatesProp) ? directionCoordinatesProp : directionCoordinatesProp.flat(); + const directionCoordinates = utils.convertSegmentedRouteToSingleSegmentRoute(directionCoordinatesProp); const noWaypoints = !waypoints || waypoints.length === 0; const {isOffline} = useNetwork(); diff --git a/src/components/MapView/MapView.tsx b/src/components/MapView/MapView.tsx index c4e1be1d8096..0053a65fcf31 100644 --- a/src/components/MapView/MapView.tsx +++ b/src/components/MapView/MapView.tsx @@ -1,6 +1,5 @@ import Button from '@components/Button'; import ImageSVG from '@components/ImageSVG'; -import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useOnyx from '@hooks/useOnyx'; @@ -8,7 +7,6 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearUserLocation, setUserLocation} from '@libs/actions/UserLocation'; -import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getCurrentPosition from '@libs/getCurrentPosition'; import type {GeolocationErrorCallback} from '@libs/getCurrentPosition/getCurrentPosition.types'; import {GeolocationErrorCode} from '@libs/getCurrentPosition/getCurrentPosition.types'; @@ -30,13 +28,11 @@ import {useSharedValue} from 'react-native-reanimated'; import type {MapViewProps} from './MapViewTypes'; import Compass from './Compass'; -import Direction from './Direction'; +import Directions from './Directions'; import MapMarkerIcon from './MapMarkerIcon'; import PendingMapView from './PendingMapView'; import responder from './responder'; -import ToggleDistanceUnitButton from './ToggleDistanceUnitButton'; import useAccessToken from './useAccessToken'; -import useDistanceUnit from './useDistanceUnit'; import utils from './utils'; function MapView({ @@ -47,7 +43,9 @@ function MapView({ pitchEnabled, initialState, waypoints, - directionCoordinates: directionCoordinatesProp, + directionCoordinates, + alternateDirection, + setIsAlternateDirectionSelected, onMapReady, interactive = true, distanceInMeters, @@ -56,8 +54,6 @@ function MapView({ shouldDisplayCurrentLocation = true, shouldDisplayCompass = true, }: MapViewProps) { - const directionCoordinates = !directionCoordinatesProp || utils.isSingleSegmentRoute(directionCoordinatesProp) ? directionCoordinatesProp : directionCoordinatesProp.flat(); - const [userLocation] = useOnyx(ONYXKEYS.USER_LOCATION); const navigation = useNavigation(); const {isOffline} = useNetwork(); @@ -73,13 +69,6 @@ function MapView({ const shouldInitializeCurrentPosition = useRef(true); const isAccessTokenSet = useAccessToken({accessToken}); - const {distanceUnit, toggleDistanceUnit} = useDistanceUnit(unit); - - const distanceLabelText = useMemo( - () => DistanceRequestUtils.getDistanceForDisplayLabel(distanceInMeters ?? 0, distanceUnit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS), - [distanceInMeters, distanceUnit], - ); - // Determines if map can be panned to user's detected // location without bothering the user. It will return // false if user has already started dragging the map or @@ -166,6 +155,8 @@ function MapView({ [], ); + const allDirectionCoordinates = utils.getCoordinatesFromAllDirections(directionCoordinates, alternateDirection); + // When the page loses focus, we temporarily set the "idled" state to false. // When the page regains focus, the onIdled method of the map will set the actual "idled" state, // which in turn triggers the callback. @@ -184,11 +175,11 @@ function MapView({ } else { const {southWest, northEast} = utils.getBounds( waypoints.map((waypoint) => waypoint.coordinate), - directionCoordinates, + allDirectionCoordinates, ); cameraRef.current?.fitBounds(northEast, southWest, mapPadding, 1000); } - }, [mapPadding, waypoints, isIdle, directionCoordinates]), + }, [mapPadding, waypoints, isIdle, allDirectionCoordinates]), ); useEffect(() => { @@ -223,8 +214,8 @@ function MapView({ const centerMap = useCallback(() => { const waypointCoordinates = waypoints?.map((waypoint) => waypoint.coordinate) ?? []; - if (waypointCoordinates.length > 1 || (directionCoordinates ?? []).length > 1) { - const {southWest, northEast} = utils.getBounds(waypoints?.map((waypoint) => waypoint.coordinate) ?? [], directionCoordinates); + if (waypointCoordinates.length > 1 || (allDirectionCoordinates ?? []).length > 1) { + const {southWest, northEast} = utils.getBounds(waypoints?.map((waypoint) => waypoint.coordinate) ?? [], allDirectionCoordinates); cameraRef.current?.fitBounds(southWest, northEast, mapPadding, CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME); return; } @@ -234,7 +225,7 @@ function MapView({ animationDuration: CONST.MAPBOX.ANIMATION_DURATION_ON_CENTER_ME, zoomLevel: CONST.MAPBOX.SINGLE_MARKER_ZOOM, }); - }, [directionCoordinates, currentPosition?.longitude, currentPosition?.latitude, mapPadding, waypoints]); + }, [allDirectionCoordinates, currentPosition?.longitude, currentPosition?.latitude, mapPadding, waypoints]); const centerCoordinate = useMemo(() => (currentPosition ? [currentPosition.longitude, currentPosition.latitude] : initialState?.location), [currentPosition, initialState?.location]); @@ -244,10 +235,10 @@ function MapView({ } const {northEast, southWest} = utils.getBounds( waypoints.map((waypoint) => waypoint.coordinate), - directionCoordinates, + allDirectionCoordinates, ); return {ne: northEast, sw: southWest}; - }, [waypoints, directionCoordinates]); + }, [waypoints, allDirectionCoordinates]); const defaultSettings: Mapbox.CameraStop | undefined = useMemo(() => { if (interactive) { @@ -270,19 +261,6 @@ function MapView({ const initCenterCoordinate = useMemo(() => (interactive ? centerCoordinate : undefined), [interactive, centerCoordinate]); const initBounds = useMemo(() => (interactive ? undefined : waypointsBounds), [interactive, waypointsBounds]); - const distanceSymbolCoordinate = useMemo(() => { - if (!directionCoordinates?.length || !waypoints?.length) { - return; - } - const {northEast, southWest} = utils.getBounds( - waypoints.map((waypoint) => waypoint.coordinate), - directionCoordinates, - ); - const boundsCenter = utils.getBoundsCenter({northEast, southWest}); - - return utils.findClosestCoordinateOnLineFromCenter(boundsCenter, directionCoordinates); - }, [waypoints, directionCoordinates]); - return !isOffline && isAccessTokenSet && !!defaultSettings ? ( } - {!!distanceSymbolCoordinate && !!distanceInMeters && !!distanceUnit && ( - - - - - {distanceLabelText} - - - - - )} + (false); const setRef = useCallback((newRef: MapRef | null) => setMapRef(newRef), []); const shouldInitializeCurrentPosition = useRef(true); @@ -253,18 +254,25 @@ function MapViewImpl({ }; }, [waypoints, directionCoordinates, interactive, currentPosition?.longitude, currentPosition?.latitude, initialState.zoom]); - const distanceSymbolCoordinate = useMemo(() => { - if (!directionCoordinates?.length || !waypoints?.length) { - return; - } - const {northEast, southWest} = utils.getBounds( - waypoints.map((waypoint) => waypoint.coordinate), - directionCoordinates, - ); - const boundsCenter = utils.getBoundsCenter({northEast, southWest}); + // The route layers only need to be interactive when there is an alternate route to pick, so that clicking a route selects it. + const interactiveLayerIds = useMemo(() => (interactive && hasAlternateDirection ? ALTERNATE_DIRECTIONS_LAYER_IDS : undefined), [interactive, hasAlternateDirection]); - return utils.findClosestCoordinateOnLineFromCenter(boundsCenter, directionCoordinates); - }, [waypoints, directionCoordinates]); + const onDrag = useCallback(() => { + setUserInteractedWithMap(true); + // Dragging must keep the grabbing cursor even when it starts on top of a route. + setIsHoveringDirection(false); + }, []); + + const selectClickedDirection = useCallback( + (event: MapMouseEvent) => { + const isAlternate: unknown = event.features?.at(0)?.properties?.isAlternate; + if (typeof isAlternate !== 'boolean') { + return; + } + setIsAlternateDirectionSelected?.(isAlternate); + }, + [setIsAlternateDirectionSelected], + ); return !isOffline && !!accessToken && !!initialViewState ? ( setUserInteractedWithMap(true)} + onDrag={onDrag} ref={setRef} mapboxAccessToken={accessToken} initialViewState={initialViewState} style={{...StyleUtils.getTextColorStyle(theme.mapAttributionText), zIndex: -1}} mapStyle={styleURL} interactive={interactive} + interactiveLayerIds={interactiveLayerIds} + onClick={selectClickedDirection} + // Only the interactive route layers report hover, so the pointer cursor shows up exclusively when there is an alternate route to pick. + onMouseEnter={() => setIsHoveringDirection(true)} + onMouseLeave={() => setIsHoveringDirection(false)} + cursor={isHoveringDirection ? 'pointer' : undefined} > {interactive && shouldDisplayCurrentLocation && ( )} - {!!distanceSymbolCoordinate && !!distanceInMeters && !!distanceUnit && ( - - - - {DistanceRequestUtils.getDistanceForDisplayLabel(distanceInMeters, distanceUnit)} - - - - )} {waypoints?.map(({coordinate, markerType, id}) => { if ( utils.areSameCoordinate([coordinate[0], coordinate[1]], [currentPosition?.longitude ?? 0, currentPosition?.latitude ?? 0]) && @@ -329,7 +325,13 @@ function MapViewImpl({ ); })} - {!!directionCoordinatesProp && } + {interactive && ( diff --git a/src/components/MapView/MapViewTypes.ts b/src/components/MapView/MapViewTypes.ts index eb90ebd047ad..8e39451c99dc 100644 --- a/src/components/MapView/MapViewTypes.ts +++ b/src/components/MapView/MapViewTypes.ts @@ -10,87 +10,168 @@ import type {SharedValue} from 'react-native-reanimated'; type Coordinate = [number, number]; type MapViewProps = { - // Public access token to be used to fetch map data from Mapbox. + /** Public access token to be used to fetch map data from Mapbox. */ accessToken: string; - // Style applied to MapView component. Note some of the View Style props are not available on ViewMap + + /** Style applied to MapView component. Note some of the View Style props are not available on ViewMap */ style: StyleProp; - // Link to the style JSON document. + + /** Link to the style JSON document. */ styleURL?: string; - // Whether map can tilt in the vertical direction. + + /** Whether map can tilt in the vertical direction. */ pitchEnabled?: boolean; - // Padding to apply when the map is adjusted to fit waypoints and directions + + /** Padding to apply when the map is adjusted to fit waypoints and directions */ mapPadding?: number; - // Initial coordinate and zoom level + + /** Initial coordinate and zoom level */ initialState?: InitialState; - // Locations on which to put markers + + /** Locations on which to put markers */ waypoints?: WayPoint[]; - // List of coordinates which together forms a direction. + + /** List of coordinates which together forms a direction. */ directionCoordinates?: Coordinate[] | Coordinate[][]; - // Callback to call when the map is idle / ready. + + /** List of coordinates which together forms an alternate direction. */ + alternateDirection?: { + /** List of coordinates which together forms an alternate direction. */ + coordinates: Coordinate[] | Coordinate[][]; + + /** Whether the alternate direction is selected */ + isSelected: boolean; + + /** Distance displayed on the map in meters. */ + distanceInMeters: number; + }; + + /** Callback to call when the alternate direction is selected */ + setIsAlternateDirectionSelected?: (isAlternateDirectionSelected: boolean) => void; + + /** Callback to call when the map is idle / ready. */ onMapReady?: () => void; - // Whether the map is interactive or not + + /** Whether the map is interactive or not */ interactive?: boolean; - // Distance displayed on the map in meters. + /** Distance displayed on the map in meters. */ distanceInMeters?: number; - // Unit of measurement for distance + /** Unit of measurement for distance */ unit?: Unit; - // Reference to the outerElement + /** Reference to the outerElement */ ref?: React.ForwardedRef; - // Whether it should display the current user's location on the map + /** Whether it should display the current user's location on the map */ shouldDisplayCurrentLocation?: boolean; - // Whether it should display the compass overlay on the map + /** Whether it should display the compass overlay on the map */ shouldDisplayCompass?: boolean; }; type CompassProps = { - // Whether the map is interactive or not + /** Whether the map is interactive or not */ interactive: boolean; - // Whether the compass should be displayed or not + /** Whether the compass should be displayed or not */ shouldDisplayCompass: boolean; - // Shared value for the map heading + /** Shared value for the map heading */ mapHeading: SharedValue; - // Reference to the camera + /** Reference to the camera */ cameraRef: RefObject; }; type GPSMapViewProps = Omit & { - // Whether the GPS trip is active + /** Whether the GPS trip is active */ isTrackingGPS: boolean; - // List of coordinates which together forms a direction. + /** List of coordinates which together forms a direction. */ directionCoordinates: Coordinate[][]; }; type GPSDirectionProps = { - // Whether the GPS trip is active + /** Whether the GPS trip is active */ isTrackingGPS: boolean; - // Last location of the user + /** Last location of the user */ lastLocation: {longitude: number; latitude: number} | undefined; - // List of coordinates which together forms a direction. + /** List of coordinates which together forms a direction. */ directionCoordinates: Coordinate[][]; - // ID of the layer to place the line layer below + /** ID of the layer to place the line layer below */ belowLayerID?: string; }; +type DistanceSymbolProps = { + /** Distance displayed on the map in meters. */ + distanceInMeters?: number; + + /** Unit of measurement for distance. */ + distanceUnit?: Unit; + + /** Toggles the unit of measurement for every symbol on the map. */ + toggleDistanceUnit: () => void; + + /** List of coordinates which together forms a direction. */ + directionCoordinates?: Coordinate[]; + + /** List of waypoints on the map */ + waypoints?: WayPoint[]; + + /** + * Whether the direction to which the symbol is assigned to is selected, true by default if not provided. + * Should be used if alternative directions are available. Determines what style to apply to the symbol. + */ + isSelected?: boolean; + + /** Selects the direction to which the symbol is assigned to. Called instead of toggling the unit when the direction is not selected. */ + selectDirection?: () => void; +}; + type DirectionProps = { - // Coordinates of points that constitute the direction + /** Coordinates of points that constitute the direction. Single segment route or multiple segments route */ coordinates: Coordinate[] | Coordinate[][]; - // ID of the layer to place the line layer below + /** ID of the layer to place the line layer below */ belowLayerID?: string; }; +type AlternateDirection = { + /** List of coordinates which together forms a direction. */ + coordinates: Coordinate[] | Coordinate[][]; + + /** Whether the alternate direction is selected */ + isSelected: boolean; + + /** Distance displayed on the map in meters. */ + distanceInMeters: number; +}; + +type DirectionsProps = { + /** List of coordinates which together forms a direction. */ + directionCoordinates?: Coordinate[] | Coordinate[][]; + + /** List of coordinates which together forms an alternate direction. */ + alternateDirection?: AlternateDirection; + + /** Callback to call when the alternate direction is selected */ + setIsAlternateDirectionSelected?: (isAlternateDirectionSelected: boolean) => void; + + /** Distance displayed on the map in meters. */ + distanceInMeters?: number; + + /** Unit of measurement for distance */ + unit?: Unit; + + /** List of waypoints on the map */ + waypoints?: WayPoint[]; +}; + type PendingMapViewProps = { /** Title message below the icon */ title?: string; @@ -105,26 +186,52 @@ type PendingMapViewProps = { isSmallerIcon?: boolean; }; -// Initial state of the map +/** Initial state of the map */ type InitialState = { - // Coordinate on which to center the map + /** Coordinate on which to center the map */ location: Coordinate; + + /** Zoom level of the map */ zoom: number; }; -// Waypoint to be displayed on the map +/** Waypoint to be displayed on the map */ type WayPoint = { + /** Unique identifier of the waypoint */ id: string; + + /** Coordinate at which the waypoint is placed */ coordinate: Coordinate; + + /** Type of the marker used to display the waypoint */ markerType: MapMarkerType; }; -// Represents a handle to interact with a map view. +/** Represents a handle to interact with a map view. */ type MapViewHandle = { - // Fly to a location on the map + /** Fly to a location on the map */ flyTo: (location: Coordinate, zoomLevel: number, animationDuration?: number) => void; - // Fit the map view to a bounding box + + /** Fit the map view to a bounding box */ fitBounds: (ne: Coordinate, sw: Coordinate, paddingConfig?: number | number[], animationDuration?: number) => void; }; -export type {WayPoint, MapViewProps, GPSMapViewProps, DirectionProps, PendingMapViewProps, Coordinate, GPSDirectionProps, CompassProps}; +type DistanceSymbolMarkerProps = {distanceSymbolCoordinate: Coordinate; children: React.ReactNode; onPress: () => void}; + +type AlternateDirectionsProps = Required> & Pick; + +export type { + WayPoint, + MapViewProps, + GPSMapViewProps, + DirectionProps, + PendingMapViewProps, + Coordinate, + GPSDirectionProps, + CompassProps, + DistanceSymbolProps, + DistanceSymbolMarkerProps, + AlternateDirectionsProps, + DirectionsProps, + AlternateDirection, +}; diff --git a/src/components/MapView/alternateDirectionsUtils.ts b/src/components/MapView/alternateDirectionsUtils.ts new file mode 100644 index 000000000000..e40a58a79855 --- /dev/null +++ b/src/components/MapView/alternateDirectionsUtils.ts @@ -0,0 +1,69 @@ +import CONST from '@src/CONST'; + +import type {Feature, FeatureCollection, LineString, MultiLineString} from 'geojson'; + +import type {AlternateDirection, Coordinate} from './MapViewTypes'; + +import utils from './utils'; + +type DirectionFeatureProperties = { + isAlternate: boolean; + isSelected: boolean; +}; + +type DirectionFeature = Feature; + +const { + SOURCE: SOURCE_ID, + UNSELECTED_FILL: UNSELECTED_FILL_ID, + UNSELECTED_BORDER: UNSELECTED_BORDER_ID, + SELECTED_FILL: SELECTED_FILL_ID, + SELECTED_BORDER: SELECTED_BORDER_ID, +} = CONST.ALTERNATE_DIRECTIONS_MAP_VIEW_LAYERS; + +function getDirectionFeature(coordinates: Coordinate[] | Coordinate[][], isAlternate: boolean, isSelected: boolean): DirectionFeature | undefined { + if (utils.isSingleSegmentRoute(coordinates)) { + if (coordinates.length < 2) { + return undefined; + } + + return { + type: 'Feature', + properties: {isAlternate, isSelected}, + geometry: { + type: 'LineString', + coordinates, + }, + }; + } + + const validSegments = coordinates.filter((segment) => segment.length >= 2); + if (validSegments.length === 0) { + return undefined; + } + + return { + type: 'Feature', + properties: {isAlternate, isSelected}, + geometry: { + type: 'MultiLineString', + coordinates: validSegments, + }, + }; +} + +/** Builds the GeoJSON shape holding both the main and the alternate route, each flagged with whether it is the selected one. */ +function getAlternateDirectionsShape( + directionCoordinates: Coordinate[] | Coordinate[][], + alternateDirection: AlternateDirection, +): FeatureCollection { + return { + type: 'FeatureCollection', + features: [ + getDirectionFeature(directionCoordinates, false, !alternateDirection.isSelected), + getDirectionFeature(alternateDirection.coordinates, true, alternateDirection.isSelected), + ].filter((feature): feature is DirectionFeature => !!feature), + }; +} + +export {SOURCE_ID, UNSELECTED_FILL_ID, UNSELECTED_BORDER_ID, SELECTED_FILL_ID, SELECTED_BORDER_ID, getAlternateDirectionsShape}; diff --git a/src/components/MapView/utils.ts b/src/components/MapView/utils.ts index ab50d6bbbed9..39aaf3aade14 100644 --- a/src/components/MapView/utils.ts +++ b/src/components/MapView/utils.ts @@ -1,4 +1,4 @@ -import type {Coordinate} from './MapViewTypes'; +import type {AlternateDirection, Coordinate} from './MapViewTypes'; /** A geographic point as a plain longitude/latitude pair. Mapbox's `LngLat` became a class in mapbox-gl 3.x, but these helpers only read `.lng`/`.lat`, so a literal shape is all that's needed. */ type LngLatLiteral = {lng: number; lat: number}; @@ -139,6 +139,22 @@ function getBoundsCenter(bounds: {southWest: Coordinate; northEast: Coordinate}) return {lng: latitudeCenter, lat: longitudeCenter}; } +/** Flattens a route made of several segments into a single list of coordinates, leaving a single segment route as is. */ +function convertSegmentedRouteToSingleSegmentRoute(directionCoordinates: Coordinate[] | Coordinate[][]): Coordinate[]; +function convertSegmentedRouteToSingleSegmentRoute(directionCoordinates: Coordinate[] | Coordinate[][] | undefined): Coordinate[] | undefined; +function convertSegmentedRouteToSingleSegmentRoute(directionCoordinates: Coordinate[] | Coordinate[][] | undefined) { + return !directionCoordinates || isSingleSegmentRoute(directionCoordinates) ? directionCoordinates : directionCoordinates.flat(); +} + +function getCoordinatesFromAllDirections(directionCoordinates: Coordinate[] | Coordinate[][] | undefined, alternateDirection: AlternateDirection | undefined) { + const directionCoordinatesFlattened = convertSegmentedRouteToSingleSegmentRoute(directionCoordinates); + + const alternateDirectionCoordinates = alternateDirection?.coordinates; + const alternateDirectionCoordinatesFlattened = convertSegmentedRouteToSingleSegmentRoute(alternateDirectionCoordinates); + + return [...(directionCoordinatesFlattened ?? []), ...(alternateDirectionCoordinatesFlattened ?? [])]; +} + export default { getBounds, areSameCoordinate, @@ -147,4 +163,6 @@ export default { getBoundsCenter, simpleInterpolateCoordinate, isSingleSegmentRoute, + convertSegmentedRouteToSingleSegmentRoute, + getCoordinatesFromAllDirections, }; diff --git a/src/components/MoneyRequestConfirmationList/sections/selectors.ts b/src/components/MoneyRequestConfirmationList/sections/selectors.ts index 8a58713d8814..0881a7d04032 100644 --- a/src/components/MoneyRequestConfirmationList/sections/selectors.ts +++ b/src/components/MoneyRequestConfirmationList/sections/selectors.ts @@ -245,7 +245,14 @@ const invoiceSenderSliceSelector = (t: OnyxEntry): OnyxEntry & { - comment: {waypoints: NonNullable['waypoints']} | undefined; + comment: + | { + waypoints: NonNullable['waypoints']; + selectedRouteKey: NonNullable['selectedRouteKey']; + // `getSelectedRouteKey` falls back to this when the frontend-only `selectedRouteKey` is absent (e.g. on an already-saved expense) + customUnit: {routeDistanceMeters: NonNullable['customUnit']>['routeDistanceMeters']} | undefined; + } + | undefined; }; const distanceMapSliceSelector = (t: OnyxEntry): OnyxEntry => { @@ -257,7 +264,13 @@ const distanceMapSliceSelector = (t: OnyxEntry): OnyxEntry & { policyID?: string; /** Used for bulk updates - JSON stringified object containing only changed fields */ updates?: string; + /** Distance in meters of the map route the user picked, sent when the selected alternate route changes */ + selectedRouteDistance?: number; }; export default UpdateMoneyRequestParams; diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 5d715ffccfec..9a626608a643 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -1175,6 +1175,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) splitsEndDate: CONST.RED_BRICK_ROAD_PENDING_ACTION, withdrawalID: CONST.RED_BRICK_ROAD_PENDING_ACTION, isAmountSet: CONST.RED_BRICK_ROAD_PENDING_ACTION, + selectedRouteKey: CONST.RED_BRICK_ROAD_PENDING_ACTION, }, 'string', ); @@ -1228,6 +1229,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) odometerStartImage: 'object', odometerEndImage: 'object', tripID: 'string', + selectedRouteKey: 'string', }); case 'accountant': return validateObject>(value, { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 9192f990d676..52289ab9c5fe 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -6243,7 +6243,7 @@ function getModifiedExpenseOriginalMessage( if ( ('customUnitRateID' in transactionChanges && updatedTransaction?.comment?.customUnit?.customUnitRateID) || - ('distance' in transactionChanges && updatedTransaction?.comment?.customUnit?.quantity) + (('distance' in transactionChanges || 'selectedRouteKey' in transactionChanges) && updatedTransaction?.comment?.customUnit?.quantity) ) { originalMessage.oldAmount = getTransactionAmount(oldTransaction, isFromExpenseReport, false, true); originalMessage.oldCurrency = getCurrency(oldTransaction); @@ -6251,7 +6251,9 @@ function getModifiedExpenseOriginalMessage( // For the originalMessage, we should use the non-negative amount, similar to what getAmount does for oldAmount originalMessage.amount = Math.abs(Number(updatedTransaction?.modifiedAmount ?? 0)); - originalMessage.currency = updatedTransaction?.modifiedCurrency ?? CONST.CURRENCY.USD; + // `modifiedCurrency` is only written when the currency actually changes (e.g. a route switch on a non-USD + // expense leaves it unset), so read through to the transaction currency instead of defaulting to USD. + originalMessage.currency = getCurrency(updatedTransaction); originalMessage.merchant = updatedTransaction?.modifiedMerchant; } diff --git a/src/libs/TransactionUtils/getDistanceInMeters.ts b/src/libs/TransactionUtils/getDistanceInMeters.ts index 8c1909327bb5..f8ddd0f39b65 100644 --- a/src/libs/TransactionUtils/getDistanceInMeters.ts +++ b/src/libs/TransactionUtils/getDistanceInMeters.ts @@ -1,8 +1,11 @@ import DistanceRequestUtils from '@libs/DistanceRequestUtils'; +import CONST from '@src/CONST'; import type {OnyxInputOrEntry, Transaction} from '@src/types/onyx'; import type {Unit} from '@src/types/onyx/Policy'; +import getSelectedRouteKey from './getSelectedRouteKey'; + // Get the distance in meters from the transaction. // This function is placed in a separate file to avoid circular dependencies. function getDistanceInMeters(transaction: OnyxInputOrEntry, unit: Unit | undefined) { @@ -15,8 +18,11 @@ function getDistanceInMeters(transaction: OnyxInputOrEntry, unit: U } // If we are creating a new distance request, the distance is available in routes.route0.distance and it's already in meters. - if (transaction?.routes?.route0?.distance) { - return transaction.routes.route0.distance; + // `getSelectedRouteKey` resolves the route the user picked (falling back to route0), so an alternate route wins here. + const selectedRouteKey = getSelectedRouteKey(transaction); + const selectedRouteDistance = transaction?.routes?.[selectedRouteKey]?.distance ?? transaction?.routes?.[CONST.TRANSACTION.DEFAULT_ROUTE_KEY]?.distance; + if (selectedRouteDistance) { + return selectedRouteDistance; } return 0; diff --git a/src/libs/TransactionUtils/getSelectedRouteKey.ts b/src/libs/TransactionUtils/getSelectedRouteKey.ts new file mode 100644 index 000000000000..04fbe4ca70f3 --- /dev/null +++ b/src/libs/TransactionUtils/getSelectedRouteKey.ts @@ -0,0 +1,49 @@ +import CONST from '@src/CONST'; +import type {OnyxInputOrEntry, Transaction} from '@src/types/onyx'; + +// Resolve which of the transaction's routes is the selected one. +// This function is placed in a separate file to avoid circular dependencies. +// +// `comment.selectedRouteKey` is a frontend-only field: it is set when the user taps a route on the map and is +// cleared whenever the waypoints change. The BE never returns it — it only echoes the distance of the route the +// expense was created with, as `comment.customUnit.routeDistanceMeters`. So for an already-saved expense (e.g. +// after a fresh login, on the edit screen) the selection has to be recovered by matching that distance against +// the re-fetched routes. +// +// `routeDistanceMeters` is the route-calculated distance, not the displayed one, so this stays correct even when +// the user has a manual `customUnit.quantity` override on top of the route. +function getSelectedRouteKey(transaction: OnyxInputOrEntry): string { + const routes = transaction?.routes; + + // A local pick wins over the BE value: it is the user's pending, not-yet-saved intent. The existence check + // guards against a key pointing at a route that a re-fetch no longer returned. + const selectedRouteKey = transaction?.comment?.selectedRouteKey; + if (selectedRouteKey && routes?.[selectedRouteKey]) { + return selectedRouteKey; + } + + const routeDistanceMeters = transaction?.comment?.customUnit?.routeDistanceMeters; + if (!routeDistanceMeters || !routes) { + return CONST.TRANSACTION.DEFAULT_ROUTE_KEY; + } + + // Whichever route is closest to the saved distance is the one the expense was created with + let closestRouteKey: string | undefined; + let smallestDifference = Number.POSITIVE_INFINITY; + for (const [key, route] of Object.entries(routes)) { + const distance = route?.distance; + if (!distance) { + continue; + } + + const difference = Math.abs(distance - routeDistanceMeters); + if (difference < smallestDifference) { + smallestDifference = difference; + closestRouteKey = key; + } + } + + return closestRouteKey ?? CONST.TRANSACTION.DEFAULT_ROUTE_KEY; +} + +export default getSelectedRouteKey; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index a2aecc635386..e0c5975ced5d 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -95,6 +95,7 @@ import lodashSet from 'lodash/set'; import Onyx from 'react-native-onyx'; import getDistanceInMeters from './getDistanceInMeters'; +import getSelectedRouteKey from './getSelectedRouteKey'; type TransactionParams = { amount: number; @@ -600,6 +601,31 @@ function getClearedPendingFields(transactionChanges: TransactionChanges) { }; } +/** + * Build the distance merchant string (e.g. "5.00 mi @ $0.70 / mi") for a recalculated distance, using the + * imperative locale accessors the optimistic update paths below have to rely on. + */ +function getRecalculatedDistanceMerchant( + transaction: OnyxEntry, + distanceInMeters: number, + unit: Unit | undefined, + rate: number | undefined, + currency: string, + getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol'], +): string { + return DistanceRequestUtils.getDistanceMerchant( + true, + distanceInMeters, + unit, + rate, + currency, + translateLocal, + (digit) => toLocaleDigit(IntlStore.getCurrentLocale(), digit), + getCurrencySymbol, + isManualDistanceRequest(transaction), + ); +} + /** * Given the edit made to the expense, return an updated transaction object. */ @@ -680,23 +706,17 @@ function getUpdatedTransaction({ // Use route distance directly since waypoints changed and the route was recalculated. // getDistanceInMeters prefers quantity which may hold a stale manually-edited value. - const distanceInMeters = transactionChanges.routes?.route0?.distance ?? getDistanceInMeters(transaction, unit); + const selectedRouteKey = transactionChanges.selectedRouteKey ?? transaction?.comment?.selectedRouteKey; + const distanceInMeters = + (selectedRouteKey ? transactionChanges.routes?.[selectedRouteKey]?.distance : undefined) ?? + transactionChanges.routes?.route0?.distance ?? + getDistanceInMeters(transaction, unit); const amount = DistanceRequestUtils.getDistanceRequestAmount(distanceInMeters, unit, rate ?? 0); const updatedAmount = isFromExpenseReport || isUnReportedExpense ? -amount : amount; // Use the rate's resolved currency (which may come from personalPolicyOutputCurrency for a P2P expense), // not transaction.currency, so the merchant symbol/rate and the recalculated amount stay in the same currency. const updatedCurrency = mileageRate.currency ?? transaction.currency ?? CONST.CURRENCY.USD; - const updatedMerchant = DistanceRequestUtils.getDistanceMerchant( - true, - distanceInMeters, - unit, - rate, - updatedCurrency, - translateLocal, - (digit) => toLocaleDigit(IntlStore.getCurrentLocale(), digit), - getCurrencySymbol, - isManualDistanceRequest(transaction), - ); + const updatedMerchant = getRecalculatedDistanceMerchant(transaction, distanceInMeters, unit, rate, updatedCurrency, getCurrencySymbol); updatedTransaction.amount = updatedAmount; updatedTransaction.modifiedAmount = updatedAmount; @@ -771,17 +791,7 @@ function getUpdatedTransaction({ const amount = DistanceRequestUtils.getDistanceRequestAmount(distanceInMeters, unit, rate ?? 0); const updatedAmount = isFromExpenseReport || isUnReportedExpense ? -amount : amount; const updatedCurrency = updatedMileageRate.currency ?? CONST.CURRENCY.USD; - const updatedMerchant = DistanceRequestUtils.getDistanceMerchant( - true, - distanceInMeters, - unit, - rate, - updatedCurrency, - translateLocal, - (digit) => toLocaleDigit(IntlStore.getCurrentLocale(), digit), - getCurrencySymbol, - isManualDistanceRequest(transaction), - ); + const updatedMerchant = getRecalculatedDistanceMerchant(transaction, distanceInMeters, unit, rate, updatedCurrency, getCurrencySymbol); updatedTransaction.amount = updatedAmount; updatedTransaction.modifiedAmount = updatedAmount; @@ -849,7 +859,6 @@ function getUpdatedTransaction({ const previousDistanceInMeters = getDistanceInMeters(transaction, transaction?.comment?.customUnit?.distanceUnit); lodashSet(updatedTransaction, 'comment.customUnit.quantity', distance); - lodashSet(updatedTransaction, 'routes.route0.distance', null); shouldStopSmartscan = true; const updatedMileageRate = DistanceRequestUtils.getRate({transaction: updatedTransaction, policy, useTransactionDistanceUnit: false, personalPolicyOutputCurrency}); @@ -867,17 +876,7 @@ function getUpdatedTransaction({ let amount = DistanceRequestUtils.getDistanceRequestAmount(distanceInMeters, unit, rate ?? 0); amount = isFromExpenseReport || isUnReportedExpense ? -amount : amount; const updatedCurrency = updatedMileageRate.currency ?? CONST.CURRENCY.USD; - const updatedMerchant = DistanceRequestUtils.getDistanceMerchant( - true, - distanceInMeters, - unit, - rate, - updatedCurrency, - translateLocal, - (digit) => toLocaleDigit(IntlStore.getCurrentLocale(), digit), - getCurrencySymbol, - isManualDistanceRequest(transaction), - ); + const updatedMerchant = getRecalculatedDistanceMerchant(transaction, distanceInMeters, unit, rate, updatedCurrency, getCurrencySymbol); // No locally resolvable rate (e.g. track expense without policy loaded) → scale the previous // amount by the distance ratio so the optimistic value isn't 0. `modifiedAmount` is `""` for @@ -895,6 +894,38 @@ function getUpdatedTransaction({ } } + // The user picked a different map route without touching the waypoints, so the routes are unchanged and only the + // distance the expense reads from them changes. Like the `distance` branch above this keeps `routes` intact, so + // the alternate routes the user can still switch between survive the edit. + // A manually typed distance always wins over the route distance, so skip this when both are being changed. + if (Object.hasOwn(transactionChanges, 'selectedRouteKey') && typeof transactionChanges.selectedRouteKey === 'string' && !Object.hasOwn(transactionChanges, 'distance')) { + const selectedRouteDistanceInMeters = updatedTransaction?.routes?.[transactionChanges.selectedRouteKey]?.distance; + lodashSet(updatedTransaction, 'comment.selectedRouteKey', transactionChanges.selectedRouteKey); + shouldStopSmartscan = true; + + if (selectedRouteDistanceInMeters) { + const mileageRate = DistanceRequestUtils.getRate({transaction: updatedTransaction, policy, personalPolicyOutputCurrency}); + const {unit, rate} = mileageRate; + const amount = DistanceRequestUtils.getDistanceRequestAmount(selectedRouteDistanceInMeters, unit, rate ?? 0); + const updatedAmount = isFromExpenseReport || isUnReportedExpense ? -amount : amount; + const updatedCurrency = mileageRate.currency ?? transaction.currency ?? CONST.CURRENCY.USD; + + updatedTransaction.amount = updatedAmount; + updatedTransaction.modifiedAmount = updatedAmount; + updatedTransaction.modifiedMerchant = getRecalculatedDistanceMerchant(transaction, selectedRouteDistanceInMeters, unit, rate, updatedCurrency, getCurrencySymbol); + if (getCurrency(updatedTransaction) !== updatedCurrency) { + updatedTransaction.modifiedCurrency = updatedCurrency; + } + + // `getDistanceInMeters` prefers `customUnit.quantity`, so it has to follow the new route or the displayed + // distance would keep showing the previously selected route. + if (unit) { + lodashSet(updatedTransaction, 'comment.customUnit.quantity', roundToTwoDecimalPlaces(DistanceRequestUtils.convertDistanceUnit(selectedRouteDistanceInMeters, unit))); + lodashSet(updatedTransaction, 'comment.customUnit.routeDistanceMeters', selectedRouteDistanceInMeters); + } + } + } + if (Object.hasOwn(transactionChanges, 'odometerStart') && typeof transactionChanges.odometerStart === 'number') { lodashSet(updatedTransaction, 'comment.odometerStart', transactionChanges.odometerStart); } @@ -3198,6 +3229,42 @@ function getIsFromGlobalCreate(transaction: OnyxEntry | Partial): number | undefined { + if (!isMapDistanceRequest(transaction) && !isDistanceTypeRequest(transaction)) { + return undefined; + } + + const selectedRouteKey = getSelectedRouteKey(transaction); + return transaction?.routes?.[selectedRouteKey]?.distance ?? undefined; +} + +/** + * Whether the transaction's displayed distance is a manually typed override rather than the distance of the map route + * it points at. `comment.customUnit.quantity` holds both cases — a value the user typed on the Manual tab and, after + * picking an alternate route, that route's distance — so the comparison has to be against the *selected* route and not + * the primary one, or every alternate route selection would look like an override. + */ +function hasManualDistanceOverride(transaction: OnyxInputOrEntry): boolean { + const quantity = transaction?.comment?.customUnit?.quantity; + const selectedRouteDistanceInMeters = transaction?.routes?.[getSelectedRouteKey(transaction)]?.distance; + if (quantity == null || !selectedRouteDistanceInMeters) { + return false; + } + + const unit = transaction?.comment?.customUnit?.distanceUnit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; + const quantityMatchesDistance = (distanceInMeters: number) => quantity === roundToTwoDecimalPlaces(DistanceRequestUtils.convertDistanceUnit(distanceInMeters, unit)); + + // The saved quantity was computed from the route distance at creation time (`routeDistanceMeters`); a later + // re-fetch can return a slightly different distance for the same route, which must not read as an override. + const routeDistanceMeters = transaction?.comment?.customUnit?.routeDistanceMeters; + return !quantityMatchesDistance(selectedRouteDistanceInMeters) && !(routeDistanceMeters && quantityMatchesDistance(routeDistanceMeters)); +} + export { buildOptimisticTransaction, calculateTaxAmount, @@ -3224,6 +3291,9 @@ export { getCurrency, shouldClearConvertedAmount, getDistanceInMeters, + getSelectedRouteDistance, + getSelectedRouteKey, + hasManualDistanceOverride, getCardID, getOriginalCurrency, getOriginalAmount, diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 53dd27f6aa0e..780f6756d5d6 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -63,7 +63,7 @@ type ViolationTranslationParams = { connectionLink?: string; card?: Card; isMarkAsCash?: boolean; - routeDistanceMeters?: number; + routeDistanceMeters?: number | null; distanceUnit?: Unit; }; diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index efef5ad1d75d..a94ea32293c0 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -122,6 +122,9 @@ type DistanceRequestTransactionParams = BaseTransactionParams & { odometerEnd?: number; gpsCoordinates?: string; distanceRequestType?: string; + + /** Distance in meters of the alternate map route the user picked, so the backend doesn't fall back to the primary route */ + selectedRouteDistance?: number; }; type CreateDistanceRequestInformation = { @@ -2020,6 +2023,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest isFromGlobalCreate, gpsCoordinates, distanceRequestType, + selectedRouteDistance, } = transactionParams; // If the report is an iou or expense report, we should get the linked chat report to be passed to the getMoneyRequestInformation function @@ -2112,6 +2116,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest odometerEnd, gpsCoordinates, distanceRequestType, + selectedRouteDistance, }; } else { const participant = participants.at(0) ?? {}; @@ -2234,6 +2239,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest attendees: attendees ? JSON.stringify(attendees) : undefined, gpsCoordinates, distanceRequestType, + selectedRouteDistance, shouldDeferAutoSubmit, }; } diff --git a/src/libs/actions/IOU/SplitExpenseItems.ts b/src/libs/actions/IOU/SplitExpenseItems.ts index 050bd866d86f..818325ca50c0 100644 --- a/src/libs/actions/IOU/SplitExpenseItems.ts +++ b/src/libs/actions/IOU/SplitExpenseItems.ts @@ -4,10 +4,17 @@ import DateUtils from '@libs/DateUtils'; import {calculateAmount as calculateIOUAmount} from '@libs/IOUUtils'; import {toLocaleDigit} from '@libs/LocaleDigitUtils'; import {translate} from '@libs/Localize'; -import {rand64} from '@libs/NumberUtils'; +import {rand64, roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import {getDistanceRateCustomUnitRate} from '@libs/PolicyUtils'; import {getTransactionDetails, isSelfDM} from '@libs/ReportUtils'; -import {buildOptimisticTransaction, getAmount, getCurrency, isDistanceRequest as isDistanceRequestTransactionUtils} from '@libs/TransactionUtils'; +import { + buildOptimisticTransaction, + getAmount, + getCurrency, + getSelectedRouteKey, + hasManualDistanceOverride, + isDistanceRequest as isDistanceRequestTransactionUtils, +} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; @@ -598,12 +605,16 @@ function updateSplitExpenseField( if (transactionDetails?.created !== item.created) { shouldResetDateRange = true; } - let quantity: number | undefined; - if (splitExpenseDraftTransaction?.routes?.route0?.distance && splitExpenseDraftTransaction?.comment?.customUnit?.distanceUnit) { - quantity = DistanceRequestUtils.convertDistanceUnit(splitExpenseDraftTransaction?.routes?.route0?.distance, splitExpenseDraftTransaction?.comment?.customUnit?.distanceUnit); - } else { - quantity = splitExpenseDraftTransaction?.comment?.customUnit?.quantity ?? 0; - } + const splitSelectedRouteKey = getSelectedRouteKey(splitExpenseDraftTransaction); + const splitDistanceUnit = splitExpenseDraftTransaction?.comment?.customUnit?.distanceUnit; + const splitSelectedRouteDistanceInMeters = splitExpenseDraftTransaction?.routes?.[splitSelectedRouteKey]?.distance; + // A distance the user typed on the Manual tab outranks the distance of the route it sits on top of, the same + // precedence `getDistanceInMeters` applies. The routes stay populated through a manual distance edit, so + // reading them first here would silently replace the override when any other field of the split is saved. + const quantity = + splitSelectedRouteDistanceInMeters && splitDistanceUnit && !hasManualDistanceOverride(splitExpenseDraftTransaction) + ? roundToTwoDecimalPlaces(DistanceRequestUtils.convertDistanceUnit(splitSelectedRouteDistanceInMeters, splitDistanceUnit)) + : (splitExpenseDraftTransaction?.comment?.customUnit?.quantity ?? 0); const updatedItem: SplitExpense = { ...item, @@ -629,28 +640,15 @@ function updateSplitExpenseField( const {unit, rate} = mileageRate; if (rate && rate > 0) { - // Get distance from routes or customUnit.quantity (same logic as in initSplitExpense) - let distanceInUnits: number | undefined; - if (splitExpenseDraftTransaction?.routes?.route0?.distance && splitExpenseDraftTransaction?.comment?.customUnit?.distanceUnit) { - distanceInUnits = DistanceRequestUtils.convertDistanceUnit( - splitExpenseDraftTransaction.routes.route0.distance, - splitExpenseDraftTransaction.comment.customUnit.distanceUnit, - ); - } else { - distanceInUnits = splitExpenseDraftTransaction?.comment?.customUnit?.quantity ?? 0; - } - - if (distanceInUnits !== undefined) { - // Calculate amount from distance and rate: amount = distance * rate - // Both amount and rate are in cents, distance is in units - const sign = item.amount < 0 ? -1 : 1; - const calculatedAmount = distanceInUnits > 0 ? Math.round(distanceInUnits * rate) * sign : 0; - updatedItem.amount = calculatedAmount; - - // Update merchant for distance transactions - const currency = originalTransaction.currency ?? mileageRate?.currency ?? CONST.CURRENCY.USD; - updatedItem.merchant = getDistanceMerchantFromDistance(distanceInUnits, unit, rate, currency, getCurrencySymbol); - } + // Calculate amount from the same distance `quantity` resolved to, so the amount and merchant can't + // drift from the stored distance: amount = distance * rate. + // Both amount and rate are in cents, distance is in units + const sign = item.amount < 0 ? -1 : 1; + updatedItem.amount = quantity > 0 ? Math.round(quantity * rate) * sign : 0; + + // Update merchant for distance transactions + const currency = originalTransaction.currency ?? mileageRate?.currency ?? CONST.CURRENCY.USD; + updatedItem.merchant = getDistanceMerchantFromDistance(quantity, unit, rate, currency, getCurrencySymbol); } } diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index 46cfa57459fe..a96a6afa0e5e 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -2458,6 +2458,7 @@ function trackExpense(params: CreateTrackExpenseParams) { isFromGlobalCreate = false, gpsCoordinates, distanceRequestType, + selectedRouteDistance, } = transactionData; const isMoneyRequestReport = isMoneyRequestReportReportUtils(report); const currentChatReport = isMoneyRequestReport ? getReportOrDraftReport(report?.chatReportID) : report; @@ -2502,6 +2503,7 @@ function trackExpense(params: CreateTrackExpenseParams) { linkedTrackedExpenseReportAction, linkedTrackedExpenseReportID, customUnitRateID, + selectedRouteDistance, }, quickAction, isSelfTourViewed, @@ -2655,6 +2657,7 @@ function trackExpense(params: CreateTrackExpenseParams) { waypoints: sanitizedWaypoints, customUnitRateID: mileageRate, attendees, + selectedRouteDistance, }; const policyParams: TrackedExpensePolicyParams = { policyID: chatReport?.policyID, @@ -2708,6 +2711,7 @@ function trackExpense(params: CreateTrackExpenseParams) { waypoints: sanitizedWaypoints, customUnitRateID: mileageRate, attendees, + selectedRouteDistance, }; const policyParams: TrackedExpensePolicyParams = { policyID: chatReport?.policyID, @@ -2760,6 +2764,7 @@ function trackExpense(params: CreateTrackExpenseParams) { waypoints: sanitizedWaypoints, customUnitRateID: mileageRate, attendees, + selectedRouteDistance, }; const policyParams: TrackedExpensePolicyParams = { policyID: chatReport?.policyID, @@ -2841,6 +2846,7 @@ function trackExpense(params: CreateTrackExpenseParams) { description: parsedComment, gpsCoordinates, distanceRequestType, + selectedRouteDistance, isDistance: isGPSDistanceRequest || isMapDistanceRequest(transaction) || diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index d95ab04f8e53..117bc16009ef 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -950,6 +950,7 @@ type UpdateMoneyRequestDistanceParams = { recentWaypoints: OnyxEntry; distance?: number; routes?: Routes; + selectedRouteKey?: string; policy: OnyxEntry; policyTagList: OnyxEntry; policyCategories: OnyxEntry; @@ -978,6 +979,7 @@ function updateMoneyRequestDistance({ recentWaypoints = [], distance, routes = undefined, + selectedRouteKey, policy, policyTagList, policyCategories, @@ -1004,6 +1006,7 @@ function updateMoneyRequestDistance({ // and report preview before the server response restores it. ...(routes !== undefined && {routes}), ...(distance && {distance}), + ...(selectedRouteKey !== undefined && {selectedRouteKey}), ...(odometerStart !== undefined && {odometerStart}), ...(odometerEnd !== undefined && {odometerEnd}), }; @@ -1051,6 +1054,10 @@ function updateMoneyRequestDistance({ if (odometerEnd !== undefined) { params.odometerEnd = odometerEnd; } + const selectedRouteDistance = selectedRouteKey ? transaction?.routes?.[selectedRouteKey]?.distance : undefined; + if (selectedRouteDistance) { + params.selectedRouteDistance = selectedRouteDistance; + } if (!distance) { const recentServerValidatedWaypoints = recentWaypoints.filter((item) => !item.pendingAction); @@ -1076,6 +1083,9 @@ function updateMoneyRequestDistance({ acc[key] = transactionBackup.modifiedWaypoints?.[key] ? {...transactionBackup.modifiedWaypoints?.[key]} : null; return acc; }, {}); + // A route switch alone leaves the routes untouched, so keep them instead of forcing a re-fetch that would + // briefly blank the map. + const shouldClearRoutes = routes !== undefined || haveWaypointAddressesChanged(transactionBackup?.comment?.waypoints, waypoints); onyxData?.failureData?.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction?.transactionID}`, @@ -1084,10 +1094,13 @@ function updateMoneyRequestDistance({ waypoints: onyxWaypoints, customUnit: { quantity: transactionBackup?.comment?.customUnit?.quantity, + routeDistanceMeters: transactionBackup?.comment?.customUnit?.routeDistanceMeters, }, + // When the routes are cleared below, restore the backed up selection instead of keeping a key that points at a route that no longer exists + selectedRouteKey: transactionBackup?.comment?.selectedRouteKey ?? null, }, modifiedWaypoints: onyxModifiedWaypoints, - routes: null, + ...(shouldClearRoutes && {routes: null}), }, }); } @@ -1582,7 +1595,8 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U // delivers the new `receipt.source`, keeping `ReportActionItemImage` on `ConfirmedRoute` so the // stale receipt URL doesn't briefly render. It also drives the Distance row's offline-feedback // strikethrough for pure distance edits. - const shouldFlagMerchantPending = 'waypoints' in transactionChanges || 'distance' in transactionChanges || 'customUnitRateID' in transactionChanges; + const shouldFlagMerchantPending = + 'waypoints' in transactionChanges || 'distance' in transactionChanges || 'customUnitRateID' in transactionChanges || 'selectedRouteKey' in transactionChanges; if (shouldFlagMerchantPending) { pendingFields.merchant = CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE; } @@ -1975,6 +1989,7 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U const hasModifiedDate = 'date' in transactionChanges; const hasModifiedDistance = 'distance' in transactionChanges; const hasModifiedAttendees = 'attendees' in transactionChanges; + const hasModifiedSelectedRouteKey = 'selectedRouteKey' in transactionChanges; const isInvoice = isInvoiceReportReportUtils(iouReport); if ( @@ -1990,6 +2005,7 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U hasModifiedMerchant || hasModifiedDistanceRate || hasModifiedDistance || + hasModifiedSelectedRouteKey || hasModifiedDate || hasModifiedCurrency || hasModifiedAmount || @@ -2018,7 +2034,7 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U if (hasPendingWaypoints) { optimisticViolations = optimisticViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.NO_ROUTE); } - if (hasModifiedDistanceRate || hasModifiedDistance || hasPendingWaypoints) { + if (hasModifiedDistanceRate || hasModifiedDistance || hasModifiedSelectedRouteKey || hasPendingWaypoints) { // Clear stale distance-related violations while the server reprocesses. // The server will re-evaluate and re-add any that legitimately apply. optimisticViolations = optimisticViolations.filter( diff --git a/src/libs/actions/IOU/types/TrackExpenseTransactionParams.ts b/src/libs/actions/IOU/types/TrackExpenseTransactionParams.ts index 3e98b43a87a5..8ab23f2a1b6f 100644 --- a/src/libs/actions/IOU/types/TrackExpenseTransactionParams.ts +++ b/src/libs/actions/IOU/types/TrackExpenseTransactionParams.ts @@ -36,6 +36,9 @@ type TrackExpenseTransactionParams = { isFromGlobalCreate?: boolean; gpsCoordinates?: string; distanceRequestType?: string; + + /** Distance in meters of the alternate map route the user picked, so the backend doesn't fall back to the primary route */ + selectedRouteDistance?: number; }; export type {TrackExpenseTransactionParams, GPSPoint}; diff --git a/src/libs/actions/IOU/types/TrackedExpenseParams.ts b/src/libs/actions/IOU/types/TrackedExpenseParams.ts index b6fc9df92e93..c5b8cee90e6f 100644 --- a/src/libs/actions/IOU/types/TrackedExpenseParams.ts +++ b/src/libs/actions/IOU/types/TrackedExpenseParams.ts @@ -45,6 +45,9 @@ type TrackedExpenseTransactionParams = Omit, currentIndex: strin if (!isRemovedWaypointEmpty) { newTransaction = { ...newTransaction, + comment: { + ...newTransaction.comment, + customUnit: { + ...newTransaction.comment?.customUnit, + // Belongs to the route computed for the old waypoints. Leaving it would make `getSelectedRouteKey` + // distance-match the refetched routes against it and pick a route the user never selected. + routeDistanceMeters: null, + }, + // The route is cleared below, so the previously selected alternate route no longer exists + selectedRouteKey: null, + }, // Clear any errors that may be present, which apply to the old route errorFields: { route: null, @@ -452,7 +461,12 @@ function updateWaypoints(transactionID: string, waypoints: WaypointCollection, t waypoints: waypointsOnyxUpdate, customUnit: { quantity: null, + // Belongs to the routes computed for the old waypoints. Leaving it would make `getSelectedRouteKey` + // distance-match the refetched routes against it and pick a route the user never selected. + routeDistanceMeters: null, }, + // The routes are cleared below, so the previously selected alternate route no longer exists + selectedRouteKey: null, }, // We want to reset the amount only for draft transactions (when creating the expense). // When modifying an existing transaction, the amount will be updated on the actual IOU update operation. @@ -462,15 +476,49 @@ function updateWaypoints(transactionID: string, waypoints: WaypointCollection, t route: null, }, - // Clear the existing route so that we don't show an old route - routes: { - route0: { - // Clear the existing distance to recalculate next time - distance: null, - geometry: { - coordinates: null, - }, - }, + // Clear all existing routes so that we don't show stale routes (backend may return multiple alternatives) + routes: null, + }); +} + +function setSelectedRoute( + transactionID: string, + routeKey: string, + routeDistanceInMeters: number | undefined, + distanceUnit: Unit | undefined, + transactionState: TransactionState = CONST.TRANSACTION.STATE.CURRENT, +): Promise { + let keyPrefix; + switch (transactionState) { + case CONST.TRANSACTION.STATE.DRAFT: + keyPrefix = ONYXKEYS.COLLECTION.TRANSACTION_DRAFT; + break; + case CONST.TRANSACTION.STATE.SPLIT_DRAFT: + keyPrefix = ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT; + break; + case CONST.TRANSACTION.STATE.CURRENT: + default: + keyPrefix = ONYXKEYS.COLLECTION.TRANSACTION; + break; + } + + return Onyx.merge(`${keyPrefix}${transactionID}`, { + comment: { + selectedRouteKey: routeKey, + // `getDistanceInMeters` reads `customUnit.quantity` before it consults `selectedRouteKey`, so the quantity has to + // follow the pick or every distance/amount/merchant consumer keeps showing the previously selected route until the + // expense is saved. `routeDistanceMeters` is kept in sync because it is what `getSelectedRouteKey` distance-matches + // on when `selectedRouteKey` is absent, and what `hasManualDistanceOverride` compares against. + // This intentionally overwrites a manually typed distance: saving from the map tab drops that override anyway + // (see `shouldDropManualDistance` in IOURequestStepDistance), so writing it here keeps the pre-save state honest. + ...(routeDistanceInMeters && distanceUnit + ? { + customUnit: { + quantity: roundToTwoDecimalPlaces(DistanceRequestUtils.convertDistanceUnit(routeDistanceInMeters, distanceUnit)), + routeDistanceMeters: routeDistanceInMeters, + }, + } + : {}), }, }); } @@ -2029,4 +2077,5 @@ export { getDefaultP2PMileageRate, mergeTransactionIdsHighlightOnSearchRoute, getDuplicateTransactionDetails, + setSelectedRoute, }; diff --git a/src/pages/iou/request/step/DistanceMapTabContent.tsx b/src/pages/iou/request/step/DistanceMapTabContent.tsx index 7165fcfb9f8e..fbb3cf42ff48 100644 --- a/src/pages/iou/request/step/DistanceMapTabContent.tsx +++ b/src/pages/iou/request/step/DistanceMapTabContent.tsx @@ -11,6 +11,7 @@ import type {Policy} from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import type Transaction from '@src/types/onyx/Transaction'; import type {WaypointCollection} from '@src/types/onyx/Transaction'; +import type TransactionStateType from '@src/types/utils/TransactionStateType'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView} from 'react-native'; @@ -49,6 +50,9 @@ type DistanceMapTabContentProps = { buttonText: string; errorState: ErrorState; loadingState: LoadingState; + + /** The state of the transaction being rendered, used to persist route selection to the correct Onyx key */ + transactionState: TransactionStateType; }; function DistanceMapTabContent({ @@ -65,6 +69,7 @@ function DistanceMapTabContent({ buttonText, errorState, loadingState, + transactionState, }: DistanceMapTabContentProps) { const styles = useThemeStyles(); const isInLandscapeMode = useIsInLandscapeMode(); @@ -83,6 +88,7 @@ function DistanceMapTabContent({ transaction={transaction} policy={policy} mapContainerStyle={{minHeight: undefined}} + transactionState={transactionState} /> )} @@ -100,6 +106,7 @@ function DistanceMapTabContent({ navigateToWaypointEditPage={navigateToWaypointEditPage} transaction={transaction} policy={policy} + transactionState={transactionState} /> ) : undefined } diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 4cef2e107009..eb1f19141b4f 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -42,7 +42,7 @@ import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Nav import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; import {isPolicyExpenseChat as isPolicyExpenseChatUtil} from '@libs/ReportUtils'; -import {getDistanceInMeters, getRateID, getRequestType, haveWaypointAddressesChanged} from '@libs/TransactionUtils'; +import {getDistanceInMeters, getRateID, getRequestType, getSelectedRouteKey, hasManualDistanceOverride, haveWaypointAddressesChanged} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import type {IOUType} from '@src/CONST'; @@ -201,6 +201,18 @@ function IOURequestStepDistance({ [distanceInMeters, distanceUnit], ); + // Whether the user picked a different route than the one the expense currently sits on. + // A waypoint edit re-fetches the routes and resets the selection to the primary one, so the committed + // selection refers to routes that no longer exist and can't be compared against. In that case anything + // other than the primary route is a fresh pick the user made on the re-fetched routes. + const getHasSelectedRouteChanged = useCallback( + (committedTransaction: OnyxEntry, haveWaypointsChanged: boolean) => { + const selectedRouteKey = getSelectedRouteKey(currentTransaction); + return haveWaypointsChanged ? selectedRouteKey !== CONST.TRANSACTION.DEFAULT_ROUTE_KEY : selectedRouteKey !== getSelectedRouteKey(committedTransaction); + }, + [currentTransaction], + ); + const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ getHasUnsavedChanges: () => { // Manual distance sits in `manualNumberFormRef` until Save — gate on the mounted ref so a cleared (empty) value still counts as dirty against a committed distance. @@ -209,21 +221,27 @@ function IOURequestStepDistance({ const typedManualDistance = typedDistance ? roundToTwoDecimalPlaces(parseFloat(typedDistance)) : undefined; const manualDistanceChanged = !!manualForm && typedManualDistance !== currentDistance; // Split edits skip the transaction backup, so their pre-edit route lives in `originalSplitTransactionDraft`. - const committedWaypoints = isEditingSplit ? originalSplitTransactionDraft?.comment?.waypoints : transactionBackup?.comment?.waypoints; - return manualDistanceChanged || getWaypointsHasUnsavedChanges(transaction, committedWaypoints, waypoints, isCreatingNewRequest); + const committedTransaction = isEditingSplit ? originalSplitTransactionDraft : transactionBackup; + const waypointsChanged = getWaypointsHasUnsavedChanges(transaction, committedTransaction?.comment?.waypoints, waypoints, isCreatingNewRequest); + + const committedTransactionWithRoutes = + committedTransaction && !committedTransaction.routes ? {...committedTransaction, routes: currentTransaction?.routes} : committedTransaction; + const routeChanged = !isCreatingNewRequest && getHasSelectedRouteChanged(committedTransactionWithRoutes, waypointsChanged); + return manualDistanceChanged || waypointsChanged || routeChanged; }, }); // Track whether the user has typed in the manual tab so route re-fetches don't clobber in-progress - // input. Editing waypoints clears this (in the effect below) — a recalculated route supersedes a - // manual value the same way it would on a fresh map expense (GH #90083). + // input. Editing waypoints and picking a different route both clear this (in the effects below) — an + // explicit route change supersedes a manual value the same way it would on a fresh map expense (GH #90083). const isManuallyEditing = useRef(false); - // Keep the manual tab input in sync with the recalculated route distance: - // - On a waypoint edit, `saveWaypoint`/`updateWaypoints` clear `routes.route0.distance` (and - // `customUnit.quantity`) to null, then the BE returns the new geometry. That recalculation wins - // over any earlier manual value, so the null → value transition flows back into the manual tab - // and re-enables future syncs (GH #90082, #90083). + // Keep the manual tab input in sync with the distance of the *selected* route: + // - On a waypoint edit, `saveWaypoint`/`updateWaypoints` clear `routes` (and `customUnit.quantity`) to + // null, then the BE returns the new geometry. That recalculation wins over any earlier manual value, + // so the null → value transition flows back into the manual tab and re-enables future syncs + // (GH #90082, #90083). + // - Tapping the alternate route on the map is a value → value transition, so it flows back too. // - A re-fetch of an already-saved expense is also a null → value transition but keeps a non-null // `customUnit.quantity` (the persisted value, possibly a manual override), so we skip it there // to avoid overwriting it (GH #90082). @@ -231,9 +249,24 @@ function IOURequestStepDistance({ // `customUnit.quantity` together, so by the time `routeDistance` comes back non-null `customUnitQuantity` // is already null; the re-fetch path never clears `customUnit.quantity` at all. So the `!= null` check // below stays correct regardless of the order Onyx delivers those two updates in. - const routeDistance = currentTransaction?.routes?.route0?.distance; + const selectedRouteKeyForSync = getSelectedRouteKey(currentTransaction); + const routeDistance = currentTransaction?.routes?.[selectedRouteKeyForSync]?.distance; const customUnitQuantity = currentTransaction?.comment?.customUnit?.quantity; const lastSyncedRouteDistance = useRef(routeDistance); + + // Picking a different route on the map supersedes a manual value the same way a waypoint edit does (see the + // effect below). Without this reset the sync would be skipped and the stale typed number would be sent as + // `distance` on save, which outranks `selectedRouteKey` in `getUpdatedTransaction` and would pin the expense + // to the route the user just moved away from. + const lastSelectedRouteKey = useRef(selectedRouteKeyForSync); + useEffect(() => { + if (lastSelectedRouteKey.current === selectedRouteKeyForSync) { + return; + } + lastSelectedRouteKey.current = selectedRouteKeyForSync; + isManuallyEditing.current = false; + }, [selectedRouteKeyForSync]); + useEffect(() => { if (routeDistance == null) { // The route was cleared because the user edited waypoints — let the new value flow back @@ -488,6 +521,7 @@ function IOURequestStepDistance({ { waypoints: currentTransaction?.comment?.waypoints, routes: currentTransaction?.routes, + selectedRouteKey: getSelectedRouteKey(currentTransaction), }, policy, personalPolicy?.outputCurrency, @@ -500,18 +534,26 @@ function IOURequestStepDistance({ } // If nothing was changed, simply go to transaction thread. - // We compare addresses only because numbers are rounded vs. the backup. We also send the - // update when a manual `customUnit.quantity` override was cleared by `saveWaypoint` (a - // waypoint re-save resets the distance to the route value), so the BE re-evaluates and - // clears stale distance violations like `increasedDistance` (GH #90105). + // We compare addresses only because numbers are rounded vs. the backup. const hasRouteChanged = !deepEqual(transactionBackup?.routes, transaction?.routes); - const distanceWasReset = transactionBackup?.comment?.customUnit?.quantity != null && transactionBackup.comment.customUnit.quantity !== transaction?.comment?.customUnit?.quantity; - if (!haveWaypointAddressesChanged(transactionBackup?.comment?.waypoints, waypoints) && !distanceWasReset) { + // Picking a alternate route without waypoints change changes nothing else about the transaction, so it needs its own signal or + // the save would be skipped by the check below and the selection silently dropped. + const haveWaypointsChanged = haveWaypointAddressesChanged(transactionBackup?.comment?.waypoints, waypoints); + const selectedRouteKey = getSelectedRouteKey(currentTransaction); + const shouldUpdateSelectedRoute = getHasSelectedRouteChanged(transactionBackup, haveWaypointsChanged); + // Saving from the Map tab means the distance comes from the map route, so a manual `customUnit.quantity` + // override on the saved expense is dropped even when nothing else changed. This reads the backup rather + // than the current transaction because `saveWaypoint` clears the override locally while the BE still has + // it, so re-saving a waypoint has to reach the BE too for it to re-evaluate and clear stale distance + // violations like `increasedDistance` (GH #90105). No `distance` is ever sent from this tab — the BE + // overwrites it with the `selectedRouteDistance` that `selectedRouteKey` carries. + const selectedRouteDistanceInMeters = currentTransaction?.routes?.[selectedRouteKey]?.distance; + const shouldDropManualDistance = !!selectedRouteDistanceInMeters && hasManualDistanceOverride(transactionBackup); + if (!haveWaypointsChanged && !shouldUpdateSelectedRoute && !shouldDropManualDistance) { transactionWasSaved.current = true; navigateBackAfterSave(); return; } - const routeDistanceInUnit = currentDistanceInMeters > 0 ? roundToTwoDecimalPlaces(DistanceRequestUtils.convertDistanceUnit(currentDistanceInMeters, distanceUnit)) : undefined; if (transaction?.transactionID && report?.reportID) { updateMoneyRequestDistance({ transaction, @@ -521,7 +563,9 @@ function IOURequestStepDistance({ waypoints, recentWaypoints, ...(hasRouteChanged ? {routes: transaction?.routes} : {}), - ...(distanceWasReset && routeDistanceInUnit !== undefined ? {distance: routeDistanceInUnit} : {}), + // Sent when dropping an override too: it is what carries `selectedRouteDistance` to the BE, which is + // the distance the expense is rewritten to. + ...(shouldUpdateSelectedRoute || shouldDropManualDistance ? {selectedRouteKey} : {}), policy, policyTagList: policyTags, policyCategories, @@ -562,13 +606,11 @@ function IOURequestStepDistance({ isEditingSplit, originalSplitTransactionDraft, transactionBackup, + getHasSelectedRouteChanged, waypoints, transaction, report, - currentTransaction?.comment?.waypoints, - currentTransaction?.routes, - currentDistanceInMeters, - distanceUnit, + currentTransaction, policy, parentReport, iouReportOwnerLogin, @@ -636,12 +678,21 @@ function IOURequestStepDistance({ // If so, we must still send the update even if the distance value itself didn't change. const haveWaypointsChanged = haveWaypointAddressesChanged(transactionBackup?.comment?.waypoints, waypoints); - if (!isDistanceChanged && !isDistanceUnitChanged && !haveWaypointsChanged) { + const selectedRouteKey = getSelectedRouteKey(currentTransaction); + // Picking a route on the Map tab moves the distance the manual input is prefilled with, so on its own it leaves + // the value unchanged and the checks above would skip the save — stranding the selection in local Onyx while the + // expense keeps the previous route's distance and amount. + const shouldUpdateSelectedRoute = wasOriginallyMapDistance && getHasSelectedRouteChanged(transactionBackup, haveWaypointsChanged); + + if (!isDistanceChanged && !isDistanceUnitChanged && !haveWaypointsChanged && !shouldUpdateSelectedRoute) { transactionWasSaved.current = true; navigateBackAfterSave(); return; } + // When the route pick is the only change, the distance is the route's own — sending it would store it as a manual + // override of that route. `selectedRouteKey` alone is what carries the new distance to the BE. + const isRouteSelectionOnlyChange = shouldUpdateSelectedRoute && !isDistanceChanged && !isDistanceUnitChanged && !haveWaypointsChanged; const hasRouteChanged = haveWaypointsChanged && !deepEqual(transactionBackup?.routes, transaction?.routes); updateMoneyRequestDistance({ transaction, @@ -649,8 +700,10 @@ function IOURequestStepDistance({ parentReport, iouReportOwnerLogin, waypoints, - distance: distanceAsFloat, + ...(isRouteSelectionOnlyChange ? {} : {distance: distanceAsFloat}), ...(hasRouteChanged ? {routes: transaction?.routes} : {}), + // We need to pass selectedRouteKey to ensure that updating manual distance won't cause alternate route to be overriden with the primary one + ...(wasOriginallyMapDistance ? {selectedRouteKey} : {}), transactionBackup, policy, policyTagList: policyTags, @@ -673,38 +726,39 @@ function IOURequestStepDistance({ removeBackupTransaction(transaction?.transactionID); navigateBackAfterSave(); }, [ - translate, + transactionBackup, + getHasSelectedRouteChanged, + duplicateWaypointsError, + atLeastTwoDifferentWaypointsError, + hasRouteError, distanceRate, - transactionID, - action, - iouType, - distanceUnit, isEditingSplit, transaction, - currentTransaction?.comment?.customUnit?.distanceUnit, - splitDraftTransaction, - policy, - navigateBackAfterSave, + currentTransaction, currentDistance, + distanceUnit, waypoints, - transactionBackup, report, parentReport, iouReportOwnerLogin, + policy, policyTags, policyCategories, currentUserAccountIDParam, currentUserEmailParam, isASAPSubmitBetaEnabled, recentWaypoints, - duplicateWaypointsError, - atLeastTwoDifferentWaypointsError, - hasRouteError, delegateAccountID, distanceOriginalPolicy, reportPolicyTags, isTrackIntentUser, personalPolicy?.outputCurrency, + navigateBackAfterSave, + translate, + transactionID, + action, + iouType, + splitDraftTransaction, getCurrencyDecimals, getCurrencySymbol, ]); @@ -756,16 +810,31 @@ function IOURequestStepDistance({ scrollViewRef={scrollViewRef} renderItem={renderItem} navigateToWaypointEditPage={navigateToWaypointEditPage} - transaction={transaction} + transaction={currentTransaction} policy={policy} submitWaypoints={submitWaypoints} buttonText={buttonText} errorState={errorState} loadingState={loadingState} + transactionState={transactionState} /> ), - [waypointItems, waypoints, extractKey, updateWaypoints, renderItem, navigateToWaypointEditPage, transaction, policy, submitWaypoints, buttonText, errorState, loadingState], + [ + waypointItems, + waypoints, + extractKey, + updateWaypoints, + renderItem, + navigateToWaypointEditPage, + currentTransaction, + policy, + submitWaypoints, + buttonText, + errorState, + loadingState, + transactionState, + ], ); const renderManualTab = useCallback( @@ -822,12 +891,13 @@ function IOURequestStepDistance({ scrollViewRef={scrollViewRef} renderItem={renderItem} navigateToWaypointEditPage={navigateToWaypointEditPage} - transaction={transaction} + transaction={currentTransaction} policy={policy} submitWaypoints={submitWaypoints} buttonText={buttonText} errorState={errorState} loadingState={loadingState} + transactionState={transactionState} /> ); diff --git a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts index 869267eabbd1..88f91d6a8ce9 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation.ts @@ -23,7 +23,7 @@ import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import {getPolicyExpenseChat, isSelfDM} from '@libs/ReportUtils'; import shouldUseDefaultExpensePolicy from '@libs/shouldUseDefaultExpensePolicy'; import {cancelSpan} from '@libs/telemetry/activeSpans'; -import {getDefaultTaxCode, getDistanceRequestType, getIsFromGlobalCreate, getValidWaypoints} from '@libs/TransactionUtils'; +import {getDefaultTaxCode, getDistanceRequestType, getIsFromGlobalCreate, getSelectedRouteDistance, getValidWaypoints} from '@libs/TransactionUtils'; import {setTransactionReport} from '@userActions/Transaction'; @@ -218,6 +218,7 @@ function handleMoneyRequestStepDistanceNavigation({ const isOdometerDistance = odometerDistance !== undefined; const isGPSDistance = gpsDistance !== undefined && gpsCoordinates !== undefined; const distanceRequestType = getDistanceRequestType(transaction); + const selectedRouteDistance = getSelectedRouteDistance(transaction); if (transaction?.splitShares && !isManualDistance && !isOdometerDistance) { resetSplitShares(transaction, undefined, undefined, currentUserAccountID); @@ -324,6 +325,7 @@ function handleMoneyRequestStepDistanceNavigation({ attendees: transaction?.comment?.attendees, gpsCoordinates, distanceRequestType, + selectedRouteDistance, odometerStart, odometerEnd, taxCode: distanceTaxCode, @@ -403,6 +405,7 @@ function handleMoneyRequestStepDistanceNavigation({ attendees: transaction?.comment?.attendees, gpsCoordinates, distanceRequestType, + selectedRouteDistance, odometerStart, odometerEnd, taxCode: distanceTaxCode, diff --git a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx index 61f22c109c77..bd1e86c50697 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx @@ -354,6 +354,7 @@ function IOURequestStepDistanceMap({ buttonText={buttonText} errorState={errorState} loadingState={loadingState} + transactionState={transactionState} /> ); diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 2cccb6ed70a6..6003fc8a6b0e 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -46,6 +46,7 @@ import { getDistanceRequestType, getIsFromGlobalCreate, getRateID, + getSelectedRouteDistance, getTaxValue, getValidWaypoints, isDistanceRequest as isDistanceRequestTransactionUtils, @@ -712,6 +713,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const modifiedItemDistance = isModifiedGPSDistanceRequest ? transactionDistance : undefined; const email = currentUserPersonalDetails.email ?? ''; + trackExpenseIOUActions({ report: trackReport, isDraftPolicy, @@ -757,6 +759,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { isFromGlobalCreate: getIsFromGlobalCreate(item), gpsCoordinates: isGPSDistanceRequest ? getStringifiedGPSCoordinates(gpsDraftDetails) : undefined, distanceRequestType, + selectedRouteDistance: getSelectedRouteDistance(item), }, accountantParams: { accountant: item.accountant, @@ -854,6 +857,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { isFromGlobalCreate: getIsFromGlobalCreate(transaction), gpsCoordinates: isGPSDistanceRequest ? getStringifiedGPSCoordinates(gpsDraftDetails) : undefined, distanceRequestType, + selectedRouteDistance: getSelectedRouteDistance(transaction), }, isASAPSubmitBetaEnabled, transactionViolations: transactionViolationsRef.current, diff --git a/src/styles/index.ts b/src/styles/index.ts index f8779cee37e5..5ff89dae1b2f 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4375,6 +4375,19 @@ const staticStyles = (theme: ThemeColors) => color: colors.productLight100, }, + alternativeDistanceLabelWrapper: { + backgroundColor: colors.green100, + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 4, + textAlign: 'center', + }, + alternativeDistanceLabelText: { + fontSize: 13, + fontWeight: FontUtils.fontWeight.bold, + color: colors.green800, + }, + productTrainingTooltipWrapper: { backgroundColor: theme.tooltipHighlightBG, borderRadius: variables.componentBorderRadiusNormal, @@ -7149,11 +7162,22 @@ const plainStyles = (theme: ThemeColors) => lineCap: 'round', } satisfies MapDirectionStyle, + alternativeMapDirection: { + lineColor: colors.green200, + lineWidth: 6, + lineCap: 'round', + }, + mapDirectionLayer: { layout: {'line-join': 'round', 'line-cap': 'round'}, paint: {'line-color': colors.green400, 'line-width': 6}, }, + alternativeMapDirectionLayer: { + layout: {'line-join': 'round', 'line-cap': 'round'}, + paint: {'line-color': colors.green200, 'line-width': 6}, + }, + mapDirectionLayerBorder: { layout: {'line-join': 'round', 'line-cap': 'round'}, paint: {'line-color': colors.green600, 'line-width': 8}, diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index 7d7a6da9db17..085422d50514 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -150,6 +150,9 @@ type Comment = { /** Odometer end image (File object with uri on web, URI string on native) */ odometerEndImage?: FileObject | string; + /** Key of the route selected by the user when multiple alternative routes are available (e.g. 'route0', 'route1') */ + selectedRouteKey?: string | null; + /** Spotnana trip ID, set on travel transactions and used to link the expense to its trip room */ tripID?: string; }; @@ -193,7 +196,7 @@ type TransactionCustomUnit = { * The distance in meters from the route Mapbox or Google Maps chose through the user supplied waypoints. * It is used to track when the user has manually increased the distance above the system-calculated route distance. */ - routeDistanceMeters?: number; + routeDistanceMeters?: number | null; /** Sub Rates for the custom unit */ subRates?: Array<{ @@ -733,6 +736,9 @@ type AdditionalTransactionChanges = { /** The unit for the distance/quantity */ quantity?: number; + /** Key of the route selected by the user when multiple alternative routes are available (e.g. 'route0', 'route1') */ + selectedRouteKey?: string; + /** Accounting-system vendor on the transaction's comment NVP. `null` clears the vendor. */ vendor?: TransactionCommentVendor | null; }; diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index f01c7f5b2a93..372b84efb02e 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -8805,6 +8805,240 @@ describe('updateSplitExpenseField', () => { expect(updatedSplit?.merchant).toContain('150'); }); + it('should keep a manual distance override instead of the selected route distance', async () => { + const customUnitRateID = 'rate-manual-override'; + const customUnitID = 'distance-unit'; + const originalTransactionID = 'orig-dist-manual-override'; + const splitExpenseTransactionID = 'split-dist-manual-override'; + // 200 miles of route, while the user typed 150 miles on the Manual tab + const routeDistanceInMeters = 321868.8; + + const policy: Policy = { + ...createRandomPolicy(1), + customUnits: { + [customUnitID]: { + customUnitID, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + enabled: true, + attributes: { + unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + rates: { + [customUnitRateID]: { + customUnitRateID, + currency: CONST.CURRENCY.USD, + rate: 100, + enabled: true, + name: 'Default Rate', + subRates: [], + }, + }, + }, + }, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await waitForBatchedUpdates(); + + const customUnit = { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID, + customUnitRateID, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }; + + const originalTransaction: Transaction = { + transactionID: originalTransactionID, + amount: -20000, + currency: 'USD', + merchant: '', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: {...customUnit, quantity: 200}, + }, + created: DateUtils.getDBTime(), + reportID: '456', + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`, originalTransaction); + await waitForBatchedUpdates(); + + const originalTransactionDraft: Transaction = { + transactionID: 'draft-orig-dist-manual-override', + amount: 20000, + currency: 'USD', + merchant: '', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + originalTransactionID, + splitExpenses: [ + { + transactionID: splitExpenseTransactionID, + amount: 20000, + description: 'Original', + created: DateUtils.getDBTime(), + customUnit: {...customUnit, quantity: 200}, + routes: {route0: {distance: routeDistanceInMeters, geometry: {coordinates: []}}}, + }, + ], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + }, + created: DateUtils.getDBTime(), + reportID: '456', + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, originalTransactionDraft); + await waitForBatchedUpdates(); + + // A manual distance edit writes the typed quantity but leaves the routes populated + const splitExpenseDraftTransaction: Transaction = { + transactionID: 'draft-split-dist-manual-override', + amount: 15000, + currency: 'USD', + merchant: '', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + comment: 'Updated description', + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + originalTransactionID, + customUnit: {...customUnit, quantity: 150}, + }, + routes: {route0: {distance: routeDistanceInMeters, geometry: {coordinates: []}}}, + created: DateUtils.getDBTime(), + reportID: '456', + }; + + updateSplitExpenseField(splitExpenseDraftTransaction, originalTransactionDraft, splitExpenseTransactionID, originalTransaction, policy, false, undefined, getCurrencySymbol); + await waitForBatchedUpdates(); + + const updatedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); + const updatedSplit = updatedDraft?.comment?.splitExpenses?.find((s) => s.transactionID === splitExpenseTransactionID); + expect(updatedSplit?.customUnit?.quantity).toBe(150); + expect(updatedSplit?.amount).toBe(15000); + expect(updatedSplit?.merchant).toContain('150'); + }); + + it('should use the selected route distance when there is no manual override', async () => { + const customUnitRateID = 'rate-selected-route'; + const customUnitID = 'distance-unit'; + const originalTransactionID = 'orig-dist-selected-route'; + const splitExpenseTransactionID = 'split-dist-selected-route'; + const primaryRouteDistanceInMeters = 160934.4; // 100 miles + const alternateRouteDistanceInMeters = 321868.8; // 200 miles + + const policy: Policy = { + ...createRandomPolicy(1), + customUnits: { + [customUnitID]: { + customUnitID, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + enabled: true, + attributes: { + unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + rates: { + [customUnitRateID]: { + customUnitRateID, + currency: CONST.CURRENCY.USD, + rate: 100, + enabled: true, + name: 'Default Rate', + subRates: [], + }, + }, + }, + }, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await waitForBatchedUpdates(); + + const customUnit = { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID, + customUnitRateID, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }; + + const originalTransaction: Transaction = { + transactionID: originalTransactionID, + amount: -10000, + currency: 'USD', + merchant: '', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: {...customUnit, quantity: 100}, + }, + created: DateUtils.getDBTime(), + reportID: '456', + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`, originalTransaction); + await waitForBatchedUpdates(); + + const originalTransactionDraft: Transaction = { + transactionID: 'draft-orig-dist-selected-route', + amount: 10000, + currency: 'USD', + merchant: '', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + originalTransactionID, + splitExpenses: [ + { + transactionID: splitExpenseTransactionID, + amount: 10000, + description: 'Original', + created: DateUtils.getDBTime(), + customUnit: {...customUnit, quantity: 100}, + routes: { + route0: {distance: primaryRouteDistanceInMeters, geometry: {coordinates: []}}, + route1: {distance: alternateRouteDistanceInMeters, geometry: {coordinates: []}}, + }, + }, + ], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + }, + created: DateUtils.getDBTime(), + reportID: '456', + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, originalTransactionDraft); + await waitForBatchedUpdates(); + + // A waypoint edit clears the quantity, then the routes come back and the user picks the alternate one, + // so that route's distance is what the split has to store + const splitExpenseDraftTransaction: Transaction = { + transactionID: 'draft-split-dist-selected-route', + amount: 10000, + currency: 'USD', + merchant: '', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + originalTransactionID, + customUnit, + selectedRouteKey: 'route1', + }, + routes: {route0: {distance: primaryRouteDistanceInMeters, geometry: {coordinates: []}}, route1: {distance: alternateRouteDistanceInMeters, geometry: {coordinates: []}}}, + created: DateUtils.getDBTime(), + reportID: '456', + }; + + updateSplitExpenseField(splitExpenseDraftTransaction, originalTransactionDraft, splitExpenseTransactionID, originalTransaction, policy, false, undefined, getCurrencySymbol); + await waitForBatchedUpdates(); + + const updatedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); + const updatedSplit = updatedDraft?.comment?.splitExpenses?.find((s) => s.transactionID === splitExpenseTransactionID); + expect(updatedSplit?.customUnit?.quantity).toBeCloseTo(200, 2); + expect(updatedSplit?.amount).toBe(20000); + expect(updatedSplit?.merchant).toContain('200'); + }); + it('should reset date range if created date is modified', async () => { const originalTransactionID = 'orig-date-reset'; const splitExpenseTransactionID = 'split-date-reset'; diff --git a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts index f17982414351..06f96d5b4110 100644 --- a/tests/actions/IOUTest/UpdateMoneyRequestTest.ts +++ b/tests/actions/IOUTest/UpdateMoneyRequestTest.ts @@ -1501,6 +1501,102 @@ describe('actions/IOU/UpdateMoneyRequest', () => { expect(transaction2AfterUpdate?.transactionID).toBe(transactionID2); }); + + it('should send the selected route distance and recalculate the distance optimistically when only the route selection changed', async () => { + // Given a map distance expense created with the primary route, where the user picked the alternate route + const transactionID = 'transaction_alternate_route'; + const parentReportID = 'parentReport_alternate_route'; + const transactionThreadReportID = 'transactionReport_alternate_route'; + const policyID = 'policy_alternate_route'; + const routes = { + route0: {distance: 1000, geometry: {type: 'LineString' as const, coordinates: [[0, 0] as [number, number]]}}, + route1: {distance: 1500, geometry: {type: 'LineString' as const, coordinates: [[1, 1] as [number, number]]}}, + }; + const waypoints = { + waypoint0: {keyForList: 'start', lat: 0, lng: 0, address: 'Start', name: 'Start'}, + waypoint1: {keyForList: 'end', lat: 1, lng: 1, address: 'End', name: 'End'}, + }; + + const transactionBackup: Transaction = { + transactionID, + amount: 1000, + currency: CONST.CURRENCY.USD, + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + merchant: '0.62 mi @ $0.70 / mi', + reportID: parentReportID, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + quantity: 0.62, + routeDistanceMeters: routes.route0.distance, + }, + waypoints, + }, + routes, + }; + const fakeTransaction: Transaction = { + ...transactionBackup, + comment: {...transactionBackup.comment, selectedRouteKey: 'route1'}, + }; + + const fakePolicy = createRandomPolicy(Number(1)); + const transactionThreadReport = {reportID: transactionThreadReportID, type: CONST.REPORT.TYPE.EXPENSE} as Report; + const parentReport = {reportID: parentReportID, type: CONST.REPORT.TYPE.IOU} as Report; + + // The stored transaction is still the one built from the primary route, so the only change is the route selection + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transactionBackup); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, transactionThreadReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`, parentReport); + + mockFetch?.pause?.(); + + // When saving the edit without touching the waypoints + updateMoneyRequestDistance({ + personalPolicyOutputCurrency: undefined, + reportPolicyTags: undefined, + transaction: fakeTransaction, + transactionThreadReport, + parentReport, + iouReportOwnerLogin: undefined, + waypoints, + recentWaypoints: [], + selectedRouteKey: 'route1', + policy: fakePolicy, + policyTagList: undefined, + policyCategories: undefined, + transactionBackup, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'test@example.com', + isASAPSubmitBetaEnabled: false, + delegateAccountID: undefined, + isTrackIntentUser: false, + getCurrencyDecimals, + getCurrencySymbol, + }); + + await waitForBatchedUpdates(); + + // Then the optimistic transaction follows the alternate route without clearing the routes + const transaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + expect(transaction?.comment?.customUnit?.quantity).toBe(0.93); + expect(transaction?.comment?.customUnit?.routeDistanceMeters).toBe(1500); + expect(transaction?.comment?.selectedRouteKey).toBe('route1'); + expect(transaction?.routes).toEqual(routes); + + await mockFetch?.resume?.(); + await waitForBatchedUpdates(); + + // And the alternate route's distance is sent to the BE, which is how it identifies the picked route + const distanceRequest = mockFetch.mock.calls.find(([url]) => typeof url === 'string' && url.endsWith(`/${WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DISTANCE}?`)); + const requestBody = distanceRequest?.[1]?.body; + const requestParams = requestBody instanceof FormData ? requestBody : undefined; + expect(requestParams?.get('transactionID')).toBe(transactionID); + expect(requestParams?.get('selectedRouteDistance')).toBe('1500'); + }); }); describe('updateMoneyRequestReimbursable', () => { diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 83cae41bd658..29a31962584f 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -9284,6 +9284,42 @@ describe('actions/Report', () => { }); }); + describe('buildOptimisticModifiedExpenseReportAction distance currency', () => { + it('keeps the expense currency when a route switch leaves modifiedCurrency unset', () => { + const oldTransaction = createMock({ + transactionID: '1', + reportID: '2', + amount: -1000, + currency: 'EUR', + merchant: '10.00 km @ €1.00 / km', + created: '2026-07-01', + comment: { + customUnit: { + customUnitRateID: 'rate1', + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, + quantity: 10, + routeDistanceMeters: 10000, + }, + }, + }); + + // A pure route switch recalculates the amount but not the currency, so `modifiedCurrency` stays unset. + const updatedTransaction = createMock({ + ...oldTransaction, + modifiedAmount: -2000, + modifiedMerchant: '20.00 km @ €1.00 / km', + comment: {customUnit: {...oldTransaction.comment?.customUnit, quantity: 20}}, + }); + + const result = ReportUtils.buildOptimisticModifiedExpenseReportAction(undefined, oldTransaction, {selectedRouteKey: 'route1'}, true, undefined, undefined, updatedTransaction); + const originalMessage = getOriginalMessage(result as OnyxTypes.ReportAction); + + expect(originalMessage?.currency).toBe('EUR'); + expect(originalMessage?.oldCurrency).toBe('EUR'); + expect(originalMessage?.amount).toBe(2000); + }); + }); + describe('buildOptimisticIOUReportAction delegateAccountID forwarding', () => { const DELEGATE_ACCOUNT_ID = 998; diff --git a/tests/ui/IOURequestStepDistanceTest.tsx b/tests/ui/IOURequestStepDistanceTest.tsx index 4de9f392785a..49921bbe393b 100644 --- a/tests/ui/IOURequestStepDistanceTest.tsx +++ b/tests/ui/IOURequestStepDistanceTest.tsx @@ -628,7 +628,7 @@ describe('IOURequestStepDistance - manual tab follows the recalculated route dis }); }); -describe('IOURequestStepDistance - re-saving a waypoint resets a manual distance override (GH #90105)', () => { +describe('IOURequestStepDistance - re-saving a waypoint with a manual distance override (GH #90105)', () => { const {updateMoneyRequestDistance} = jest.requireMock<{updateMoneyRequestDistance: jest.Mock}>('@libs/actions/IOU/UpdateMoneyRequest'); const routeMeters = DistanceRequestUtils.convertToDistanceInMeters(100, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES); // Seed the full distance transaction, then set just the route + the (possibly cleared) manual quantity. @@ -667,7 +667,7 @@ describe('IOURequestStepDistance - re-saving a waypoint resets a manual distance await Onyx.merge(ONYXKEYS.IS_LOADING_APP, false); }); - it('sends an update with the route distance when a manual override was cleared by saveWaypoint', async () => { + it('sends the route selection when a manual override was cleared by saveWaypoint', async () => { await act(async () => { // Saved state had a manual override (200 mi); current state is post-`saveWaypoint` (quantity cleared, route re-fetched to its real value). await seedDistanceTransaction(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${TRANSACTION_ID}`, 200); @@ -680,7 +680,24 @@ describe('IOURequestStepDistance - re-saving a waypoint resets a manual distance // The Map-tab Save button is the first "common.save" → submitWaypoints fireEvent.press(screen.getAllByText('common.save').at(0)!); - expect(updateMoneyRequestDistance).toHaveBeenCalledWith(expect.objectContaining({distance: expect.any(Number)})); + expect(updateMoneyRequestDistance).toHaveBeenCalledWith(expect.objectContaining({selectedRouteKey: 'route0'})); + expect(updateMoneyRequestDistance).not.toHaveBeenCalledWith(expect.objectContaining({distance: expect.anything()})); + }); + + it('sends the route selection when the manual override is still in place and no waypoint was touched', async () => { + await act(async () => { + // The user never entered the waypoint editor, so the 200 mi override is still on the current transaction. + await seedDistanceTransaction(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${TRANSACTION_ID}`, 200); + await seedDistanceTransaction(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, 200); + }); + + renderEditMode(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getAllByText('common.save').at(0)!); + + expect(updateMoneyRequestDistance).toHaveBeenCalledWith(expect.objectContaining({selectedRouteKey: 'route0'})); + expect(updateMoneyRequestDistance).not.toHaveBeenCalledWith(expect.objectContaining({distance: expect.anything()})); }); it('does not send an update when the waypoints and distance are unchanged', async () => { @@ -697,3 +714,191 @@ describe('IOURequestStepDistance - re-saving a waypoint resets a manual distance expect(updateMoneyRequestDistance).not.toHaveBeenCalled(); }); }); + +describe('IOURequestStepDistance - editing the waypoints of an expense with an alternate route selected', () => { + const {updateMoneyRequestDistance} = jest.requireMock<{updateMoneyRequestDistance: jest.Mock}>('@libs/actions/IOU/UpdateMoneyRequest'); + const miles = (value: number) => DistanceRequestUtils.convertToDistanceInMeters(value, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES); + const geometry = (coordinates: Array<[number, number]>) => ({coordinates}); + const inMiles = (meters: number) => roundToTwoDecimalPlaces(DistanceRequestUtils.convertDistanceUnit(meters, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES)); + const savedRoutes = { + route0: {distance: miles(100), geometry: geometry([[0, 0] as [number, number]])}, + route1: {distance: miles(120), geometry: geometry([[0, 1] as [number, number]])}, + }; + // The saved state: original waypoints, the routes they produced, and the given route selected. The BE only echoes + // the selection as `routeDistanceMeters`, so that is what the saved selection has to be recovered from. + const seedBackup = async (selectedRouteKey: 'route0' | 'route1' = 'route0', manualQuantity?: number) => { + const key = `${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${TRANSACTION_ID}` as const; + await Onyx.merge(key, createDistanceTransaction()); + await Onyx.merge(key, { + comment: { + customUnit: { + // A manual override replaces the displayed quantity; `routeDistanceMeters` still records the route it overrides. + quantity: manualQuantity ?? inMiles(savedRoutes[selectedRouteKey].distance), + routeDistanceMeters: savedRoutes[selectedRouteKey].distance, + }, + }, + routes: savedRoutes, + }); + }; + // The state after editing a waypoint and then reverting it to the original address: same waypoints, the routes + // re-fetched for them (identical distances), and `customUnit.quantity`/`routeDistanceMeters` cleared for good by + // `saveWaypoint`. `selectedRouteKey` is the route the user (re-)picked on the re-fetched routes. + const seedRevertedToOriginal = async (selectedRouteKey: 'route0' | 'route1') => { + const key = `${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}` as const; + await Onyx.merge(key, createDistanceTransaction()); + await Onyx.merge(key, { + comment: { + customUnit: {quantity: null, routeDistanceMeters: null}, + ...(selectedRouteKey === 'route0' ? {} : {selectedRouteKey}), + }, + routes: savedRoutes, + }); + }; + // The post-edit state: a changed waypoint, the re-fetched routes, and the given route picked on them. + const seedCurrent = async (selectedRouteKey: 'route0' | 'route1') => { + const key = `${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}` as const; + const routes = { + route0: {distance: miles(80), geometry: geometry([[1, 0]])}, + route1: {distance: miles(90), geometry: geometry([[1, 1]])}, + }; + await Onyx.merge(key, createDistanceTransaction()); + await Onyx.merge(key, { + comment: { + waypoints: {waypoint1: {address: '789 New Ave', lat: 41.5, lng: -73.5, keyForList: 'stop_waypoint'}}, + // `saveWaypoint` clears both, then the route fetch fills the quantity back in from the selected route + customUnit: { + quantity: roundToTwoDecimalPlaces(DistanceRequestUtils.convertDistanceUnit(routes[selectedRouteKey].distance, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES)), + routeDistanceMeters: null, + }, + ...(selectedRouteKey === 'route0' ? {} : {selectedRouteKey}), + }, + routes, + }); + }; + + beforeAll(() => { + Onyx.init({keys: ONYXKEYS, evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS]}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await waitForBatchedUpdates(); + await signInWithTestUser(ACCOUNT_ID, ACCOUNT_LOGIN); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, createTestReport()); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, null); + await Onyx.merge(ONYXKEYS.IS_LOADING_APP, false); + }); + + it('sends the picked route instead of a manual distance override', async () => { + await act(async () => { + await seedBackup(); + await seedCurrent('route1'); + }); + + renderEditMode(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getAllByText('common.save').at(0)!); + + // `selectedRouteKey` is what makes `updateMoneyRequestDistance` send `selectedRouteDistance`; a `distance` on top + // of it would be stored as a manual override of the route distance. + expect(updateMoneyRequestDistance).toHaveBeenCalledWith(expect.objectContaining({selectedRouteKey: 'route1'})); + expect(updateMoneyRequestDistance).not.toHaveBeenCalledWith(expect.objectContaining({distance: expect.anything()})); + }); + + it('sends the re-fetched routes and no route selection when the primary route is selected', async () => { + await act(async () => { + await seedBackup(); + await seedCurrent('route0'); + }); + + renderEditMode(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getAllByText('common.save').at(0)!); + + expect(updateMoneyRequestDistance).toHaveBeenCalledWith(expect.objectContaining({routes: expect.objectContaining({route0: expect.objectContaining({distance: miles(80)})})})); + expect(updateMoneyRequestDistance).not.toHaveBeenCalledWith(expect.objectContaining({distance: expect.anything()})); + expect(updateMoneyRequestDistance).not.toHaveBeenCalledWith(expect.objectContaining({selectedRouteKey: expect.anything()})); + }); + + it('does not send an update when the waypoints are reverted to the original ones and the same alternate route is picked', async () => { + await act(async () => { + await seedBackup('route1'); + await seedRevertedToOriginal('route1'); + }); + + renderEditMode(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getAllByText('common.save').at(0)!); + + expect(updateMoneyRequestDistance).not.toHaveBeenCalled(); + }); + + it('does not send an update when the waypoints are reverted to the original ones and the primary route stays selected', async () => { + await act(async () => { + await seedBackup('route0'); + await seedRevertedToOriginal('route0'); + }); + + renderEditMode(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getAllByText('common.save').at(0)!); + + expect(updateMoneyRequestDistance).not.toHaveBeenCalled(); + }); + + it('sends an update when the waypoints are reverted to the original ones but a different route is picked', async () => { + await act(async () => { + await seedBackup('route0'); + await seedRevertedToOriginal('route1'); + }); + + renderEditMode(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getAllByText('common.save').at(0)!); + + expect(updateMoneyRequestDistance).toHaveBeenCalledWith(expect.objectContaining({selectedRouteKey: 'route1'})); + }); + + it('drops a manual override onto the selected alternate route rather than the primary one', async () => { + await act(async () => { + // 200 mi typed on top of route1 (120 mi), then nothing but the Map-tab Save. + await seedBackup('route1', 200); + await seedRevertedToOriginal('route1'); + }); + + renderEditMode(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getAllByText('common.save').at(0)!); + + // Comparing the override against the primary route0 (100 mi) instead would read as "no override" and skip the + // save, leaving the 200 mi override on an expense the user has switched to a 120 mi route. + expect(updateMoneyRequestDistance).toHaveBeenCalledWith(expect.objectContaining({selectedRouteKey: 'route1'})); + expect(updateMoneyRequestDistance).not.toHaveBeenCalledWith(expect.objectContaining({distance: expect.anything()})); + }); + + it('keeps the selected alternate route when a manual distance is saved on a map-based expense', async () => { + await act(async () => { + await seedBackup('route1'); + await seedRevertedToOriginal('route1'); + }); + + renderEditMode(); + await waitForBatchedUpdatesWithAct(); + + // `getAllByLabelText` matches both the field label and the underlying ; pick the input. + fireEvent.changeText(screen.getAllByLabelText(/common\.distance/).find((element) => 'value' in element.props)!, '150'); + // The Manual-tab Save button is the second "common.save" → submitManualDistance + fireEvent.press(screen.getAllByText('common.save').at(1)!); + + // The manual value has to travel with the route selection: without `selectedRouteKey` the BE would fall back + // to the primary route and silently drop the user's pick. + expect(updateMoneyRequestDistance).toHaveBeenCalledWith(expect.objectContaining({distance: 150, selectedRouteKey: 'route1'})); + }); +}); diff --git a/tests/unit/MapViewUtilsTest.ts b/tests/unit/MapViewUtilsTest.ts new file mode 100644 index 000000000000..d9cc864b7131 --- /dev/null +++ b/tests/unit/MapViewUtilsTest.ts @@ -0,0 +1,89 @@ +import type {AlternateDirection, Coordinate} from '@components/MapView/MapViewTypes'; +import utils from '@components/MapView/utils'; + +const SINGLE_SEGMENT: Coordinate[] = [ + [0, 0], + [1, 1], + [2, 2], +]; + +const SEGMENTED: Coordinate[][] = [ + [ + [0, 0], + [1, 1], + ], + [ + [5, 5], + [6, 6], + ], +]; + +const buildAlternateDirection = (coordinates: Coordinate[] | Coordinate[][]): AlternateDirection => ({ + coordinates, + isSelected: false, + distanceInMeters: 1500, +}); + +describe('MapView utils', () => { + describe('convertSegmentedRouteToSingleSegmentRoute', () => { + it('returns a single segment route unchanged', () => { + expect(utils.convertSegmentedRouteToSingleSegmentRoute(SINGLE_SEGMENT)).toBe(SINGLE_SEGMENT); + }); + + it('flattens a segmented route into a single list of coordinates', () => { + expect(utils.convertSegmentedRouteToSingleSegmentRoute(SEGMENTED)).toEqual([ + [0, 0], + [1, 1], + [5, 5], + [6, 6], + ]); + }); + + it('passes through undefined and empty routes', () => { + expect(utils.convertSegmentedRouteToSingleSegmentRoute(undefined)).toBeUndefined(); + expect(utils.convertSegmentedRouteToSingleSegmentRoute([])).toEqual([]); + }); + }); + + describe('getCoordinatesFromAllDirections', () => { + it('concatenates the main and alternate direction coordinates', () => { + expect(utils.getCoordinatesFromAllDirections(SINGLE_SEGMENT, buildAlternateDirection([[9, 9]]))).toEqual([ + [0, 0], + [1, 1], + [2, 2], + [9, 9], + ]); + }); + + it('flattens segmented coordinates on both directions', () => { + expect(utils.getCoordinatesFromAllDirections(SEGMENTED, buildAlternateDirection([[[7, 7]], [[8, 8]]]))).toEqual([ + [0, 0], + [1, 1], + [5, 5], + [6, 6], + [7, 7], + [8, 8], + ]); + }); + + it('returns only the main direction when there is no alternate direction', () => { + expect(utils.getCoordinatesFromAllDirections(SINGLE_SEGMENT, undefined)).toEqual(SINGLE_SEGMENT); + }); + + it('returns only the alternate direction when there is no main direction', () => { + expect(utils.getCoordinatesFromAllDirections(undefined, buildAlternateDirection(SINGLE_SEGMENT))).toEqual(SINGLE_SEGMENT); + }); + + it('returns an empty list when there are no directions at all', () => { + expect(utils.getCoordinatesFromAllDirections(undefined, undefined)).toEqual([]); + }); + }); + + describe('isSingleSegmentRoute', () => { + it('detects single segment, segmented and empty routes', () => { + expect(utils.isSingleSegmentRoute(SINGLE_SEGMENT)).toBe(true); + expect(utils.isSingleSegmentRoute(SEGMENTED)).toBe(false); + expect(utils.isSingleSegmentRoute([])).toBe(true); + }); + }); +}); diff --git a/tests/unit/TransactionTest.ts b/tests/unit/TransactionTest.ts index 9bca60154262..9d57c9f425a4 100644 --- a/tests/unit/TransactionTest.ts +++ b/tests/unit/TransactionTest.ts @@ -2,7 +2,15 @@ import {act, renderHook, waitFor} from '@testing-library/react-native'; import useOnyx from '@hooks/useOnyx'; -import {changeTransactionsReport as changeTransactionsReportAction, dismissDuplicateTransactionViolation, markAsCash, sanitizeWaypointsForAPI, saveWaypoint} from '@libs/actions/Transaction'; +import { + changeTransactionsReport as changeTransactionsReportAction, + dismissDuplicateTransactionViolation, + markAsCash, + removeWaypoint, + sanitizeWaypointsForAPI, + saveWaypoint, + setSelectedRoute, +} from '@libs/actions/Transaction'; import DateUtils from '@libs/DateUtils'; import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; import type {buildOptimisticNextStep} from '@libs/NextStepUtils'; @@ -13,6 +21,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {TransactionViolation} from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; +import type {Unit} from '@src/types/onyx/Policy'; import type {ReportCollectionDataSet, ReportNextStep} from '@src/types/onyx/Report'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -2123,6 +2132,181 @@ describe('Transaction', () => { expect(transaction?.routes?.route0?.distance ?? null).toBeNull(); expect(transaction?.routes?.route0?.geometry?.coordinates ?? null).toBeNull(); }); + + it('should clear the selected route key so it does not point at a route that no longer exists', async () => { + const transactionID = 'txn6'; + const index = '0'; + const waypoint: RecentWaypoint = { + address: 'Clear Selected Route', + lat: 9, + lng: 10, + }; + const existingTransaction = generateTransaction({transactionID, reportID: '1'}); + existingTransaction.comment = { + ...existingTransaction.comment, + selectedRouteKey: 'route1', + customUnit: {...existingTransaction.comment?.customUnit, routeDistanceMeters: 200}, + }; + existingTransaction.routes = { + route0: {distance: 100, geometry: {coordinates: [[0, 0]]}}, + route1: {distance: 200, geometry: {coordinates: [[1, 1]]}}, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, existingTransaction); + + saveWaypoint({transactionID, index, waypoint, isDraft: false, recentWaypointsList: []}); + await waitForBatchedUpdates(); + + const transaction = await OnyxUtils.get(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + expect(transaction?.comment?.selectedRouteKey ?? null).toBeNull(); + + // The route distance belongs to the old waypoints, so it must not survive to be distance-matched + // against the refetched routes — otherwise the selection would jump to a route the user never picked. + expect(transaction?.comment?.customUnit?.routeDistanceMeters ?? null).toBeNull(); + expect( + TransactionUtils.getSelectedRouteKey({ + ...existingTransaction, + comment: transaction?.comment, + routes: {route0: {distance: 500, geometry: {coordinates: [[0, 0]]}}, route1: {distance: 200, geometry: {coordinates: [[1, 1]]}}}, + }), + ).toBe(CONST.TRANSACTION.DEFAULT_ROUTE_KEY); + }); + }); + + describe('removeWaypoint', () => { + it('should clear the selected route key when the removed waypoint invalidates the route', async () => { + const transactionID = 'txn7'; + const existingTransaction = generateTransaction({transactionID, reportID: '1'}); + existingTransaction.comment = { + ...existingTransaction.comment, + selectedRouteKey: 'route1', + customUnit: {...existingTransaction.comment?.customUnit, routeDistanceMeters: 200}, + waypoints: { + waypoint0: {address: 'A', lat: 1, lng: 1}, + waypoint1: {address: 'B', lat: 2, lng: 2}, + waypoint2: {address: 'C', lat: 3, lng: 3}, + }, + }; + existingTransaction.routes = { + route0: {distance: 100, geometry: {coordinates: [[0, 0]]}}, + route1: {distance: 200, geometry: {coordinates: [[1, 1]]}}, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, existingTransaction); + + await removeWaypoint(existingTransaction, '1'); + await waitForBatchedUpdates(); + + const transaction = await OnyxUtils.get(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + expect(transaction?.comment?.selectedRouteKey ?? null).toBeNull(); + expect(transaction?.comment?.customUnit?.routeDistanceMeters ?? null).toBeNull(); + }); + }); + + describe('setSelectedRoute', () => { + const ROUTE0_DISTANCE_METERS = 16093.44; // 10 mi + const ROUTE1_DISTANCE_METERS = 32186.88; // 20 mi + + function buildRoutedTransaction(transactionID: string, distanceUnit?: Unit) { + const transaction = generateTransaction({transactionID, reportID: '1'}); + transaction.comment = { + ...transaction.comment, + selectedRouteKey: CONST.TRANSACTION.DEFAULT_ROUTE_KEY, + customUnit: { + ...transaction.comment?.customUnit, + quantity: 10, + routeDistanceMeters: ROUTE0_DISTANCE_METERS, + ...(distanceUnit ? {distanceUnit} : {}), + }, + }; + transaction.routes = { + route0: {distance: ROUTE0_DISTANCE_METERS, geometry: {coordinates: [[0, 0]]}}, + route1: {distance: ROUTE1_DISTANCE_METERS, geometry: {coordinates: [[1, 1]]}}, + }; + return transaction; + } + + it.each([ + [CONST.TRANSACTION.STATE.CURRENT, ONYXKEYS.COLLECTION.TRANSACTION], + [CONST.TRANSACTION.STATE.DRAFT, ONYXKEYS.COLLECTION.TRANSACTION_DRAFT], + [CONST.TRANSACTION.STATE.SPLIT_DRAFT, ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT], + ] as const)('writes the quantity of the newly selected route to the %s transaction', async (transactionState, keyPrefix) => { + const transactionID = `selectedRoute_${transactionState}`; + const existingTransaction = buildRoutedTransaction(transactionID); + await Onyx.merge(`${keyPrefix}${transactionID}`, existingTransaction); + + await setSelectedRoute(transactionID, CONST.TRANSACTION.ALTERNATE_ROUTE_KEY, ROUTE1_DISTANCE_METERS, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, transactionState); + await waitForBatchedUpdates(); + + const transaction = await OnyxUtils.get(`${keyPrefix}${transactionID}`); + expect(transaction?.comment?.selectedRouteKey).toBe(CONST.TRANSACTION.ALTERNATE_ROUTE_KEY); + expect(transaction?.comment?.customUnit?.quantity).toBe(20); + expect(transaction?.comment?.customUnit?.routeDistanceMeters).toBe(ROUTE1_DISTANCE_METERS); + }); + + it('makes the displayed distance follow the newly selected route instead of the stale quantity', async () => { + const transactionID = 'selectedRoute_distance'; + const existingTransaction = buildRoutedTransaction(transactionID); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, existingTransaction); + + await setSelectedRoute(transactionID, CONST.TRANSACTION.ALTERNATE_ROUTE_KEY, ROUTE1_DISTANCE_METERS, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES); + await waitForBatchedUpdates(); + + const transaction = await OnyxUtils.get(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + expect(TransactionUtils.getDistanceInMeters(transaction, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES)).toBeCloseTo(ROUTE1_DISTANCE_METERS, 1); + + expect(TransactionUtils.hasManualDistanceOverride(transaction)).toBe(false); + }); + + it('overwrites a manually typed distance override', async () => { + const transactionID = 'selectedRoute_override'; + const existingTransaction = buildRoutedTransaction(transactionID); + existingTransaction.comment = {...existingTransaction.comment, customUnit: {...existingTransaction.comment?.customUnit, quantity: 999}}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, existingTransaction); + + await setSelectedRoute(transactionID, CONST.TRANSACTION.ALTERNATE_ROUTE_KEY, ROUTE1_DISTANCE_METERS, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES); + await waitForBatchedUpdates(); + + const transaction = await OnyxUtils.get(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + expect(transaction?.comment?.customUnit?.quantity).toBe(20); + }); + + it("converts using the transaction's own distance unit", async () => { + const transactionID = 'selectedRoute_km'; + const existingTransaction = buildRoutedTransaction(transactionID, CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, existingTransaction); + + await setSelectedRoute(transactionID, CONST.TRANSACTION.ALTERNATE_ROUTE_KEY, ROUTE1_DISTANCE_METERS, CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); + await waitForBatchedUpdates(); + + const transaction = await OnyxUtils.get(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + expect(transaction?.comment?.customUnit?.quantity).toBe(32.19); + }); + + it('only writes the selected route key when the route has no distance', async () => { + const transactionID = 'selectedRoute_noDistance'; + const existingTransaction = buildRoutedTransaction(transactionID); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, existingTransaction); + + await setSelectedRoute(transactionID, CONST.TRANSACTION.ALTERNATE_ROUTE_KEY, undefined, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES); + await waitForBatchedUpdates(); + + const transaction = await OnyxUtils.get(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + expect(transaction?.comment?.selectedRouteKey).toBe(CONST.TRANSACTION.ALTERNATE_ROUTE_KEY); + expect(transaction?.comment?.customUnit?.quantity).toBe(10); + expect(transaction?.comment?.customUnit?.routeDistanceMeters).toBe(ROUTE0_DISTANCE_METERS); + }); + + it('only writes the selected route key when the distance unit is unknown', async () => { + const transactionID = 'selectedRoute_noUnit'; + const existingTransaction = buildRoutedTransaction(transactionID); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, existingTransaction); + + await setSelectedRoute(transactionID, CONST.TRANSACTION.ALTERNATE_ROUTE_KEY, ROUTE1_DISTANCE_METERS, undefined); + await waitForBatchedUpdates(); + + const transaction = await OnyxUtils.get(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + expect(transaction?.comment?.selectedRouteKey).toBe(CONST.TRANSACTION.ALTERNATE_ROUTE_KEY); + expect(transaction?.comment?.customUnit?.quantity).toBe(10); + }); }); describe('sanitizeWaypointsForAPI', () => { diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 96630562ccbe..50016c92f3bc 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -493,6 +493,48 @@ describe('TransactionUtils', () => { }); }); + it('leaves the existing routes intact when only the distance changes', () => { + // A manual distance edit used to null out `routes.route0.distance`. That also wipes the alternate routes + // the Map tab and the footer read from, so the routes are left untouched now and the manually typed value + // lives only in `customUnit.quantity`. + const routes = { + route0: { + distance: 16093.44, + geometry: { + coordinates: [[0, 0] as [number, number], [1, 1] as [number, number]], + }, + }, + route1: { + distance: 19312.13, + geometry: { + coordinates: [[0, 0] as [number, number], [2, 2] as [number, number]], + }, + }, + }; + const transaction = generateTransaction({ + comment: { + customUnit: { + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + quantity: 10, + }, + }, + routes, + }); + + const updatedTransaction = TransactionUtils.getUpdatedTransaction({ + transaction, + isFromExpenseReport: false, + policy: undefined, + transactionChanges: {distance: 20}, + personalPolicyOutputCurrency: undefined, + getCurrencyDecimals, + getCurrencySymbol, + }); + + expect(updatedTransaction.routes).toEqual(routes); + expect(updatedTransaction.comment?.customUnit?.quantity).toBe(20); + }); + it('threads personalPolicyOutputCurrency into the recalculated rate for a P2P distance expense with no policy', async () => { // A P2P distance expense (FAKE_P2P_ID) has no policy rate, so the mileage currency comes from the // resolved personal-policy currency that getUpdatedTransaction forwards to getRate. getRateForP2P only @@ -4153,3 +4195,158 @@ describe('doesMoneyRequestDraftHaveUserInput', () => { expect(doesMoneyRequestDraftHaveUserInput(transaction)).toBe(true); }); }); + +describe('getSelectedRouteDistance', () => { + const routes = { + route0: {distance: 1000, geometry: {type: 'LineString' as const, coordinates: [[0, 0] as [number, number]]}}, + route1: {distance: 1500, geometry: {type: 'LineString' as const, coordinates: [[1, 1] as [number, number]]}}, + }; + + it('returns the selected route distance in meters when an alternate route is selected', () => { + const transaction = generateTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: {selectedRouteKey: 'route1'}, routes}); + expect(TransactionUtils.getSelectedRouteDistance(transaction)).toBe(1500); + }); + + it('returns the default route distance when the default route is selected or no route is selected', () => { + const transaction = generateTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: {selectedRouteKey: 'route0'}, routes}); + expect(TransactionUtils.getSelectedRouteDistance(transaction)).toBe(1000); + expect(TransactionUtils.getSelectedRouteDistance(generateTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, routes}))).toBe(1000); + }); + + it('falls back to the default route when the selected route is no longer available', () => { + const transaction = generateTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, comment: {selectedRouteKey: 'route1'}, routes: {route0: routes.route0}}); + expect(TransactionUtils.getSelectedRouteDistance(transaction)).toBe(1000); + }); + + it('returns undefined when the selected route has no distance', () => { + const nullDistance = generateTransaction({ + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: {selectedRouteKey: 'route1'}, + routes: {route1: {...routes.route1, distance: null}}, + }); + expect(TransactionUtils.getSelectedRouteDistance(nullDistance)).toBeUndefined(); + }); + + it('returns undefined when the transaction has no routes', () => { + expect(TransactionUtils.getSelectedRouteDistance(generateTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP}))).toBeUndefined(); + }); + + it('returns the selected route distance for the legacy distance request type', () => { + const transaction = generateTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, comment: {selectedRouteKey: 'route1'}, routes}); + expect(TransactionUtils.getSelectedRouteDistance(transaction)).toBe(1500); + expect(TransactionUtils.getSelectedRouteDistance(generateTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, routes}))).toBe(1000); + }); + + it('returns undefined for distance requests that are not map based', () => { + const transaction = generateTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, comment: {selectedRouteKey: 'route1'}, routes}); + expect(TransactionUtils.getSelectedRouteDistance(transaction)).toBeUndefined(); + expect(TransactionUtils.getSelectedRouteDistance(undefined)).toBeUndefined(); + }); +}); + +describe('getSelectedRouteKey', () => { + const routes = { + route0: {distance: 1000, geometry: {type: 'LineString' as const, coordinates: [[0, 0] as [number, number]]}}, + route1: {distance: 1500, geometry: {type: 'LineString' as const, coordinates: [[1, 1] as [number, number]]}}, + }; + + it('returns the explicitly selected route', () => { + const transaction = generateTransaction({comment: {selectedRouteKey: 'route1', customUnit: {routeDistanceMeters: 1000}}, routes}); + expect(TransactionUtils.getSelectedRouteKey(transaction)).toBe('route1'); + }); + + it('derives the selection from the saved route distance when there is no explicit selection', () => { + const transaction = generateTransaction({comment: {customUnit: {routeDistanceMeters: 1500}}, routes}); + expect(TransactionUtils.getSelectedRouteKey(transaction)).toBe('route1'); + }); + + it('picks the closest route when the saved distance does not match exactly', () => { + const transaction = generateTransaction({comment: {customUnit: {routeDistanceMeters: 1493.27}}, routes}); + expect(TransactionUtils.getSelectedRouteKey(transaction)).toBe('route1'); + }); + + it('derives the selection from the route distance even when a manual distance override is set', () => { + const transaction = generateTransaction({comment: {customUnit: {quantity: 42, distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, routeDistanceMeters: 1500}}, routes}); + expect(TransactionUtils.getSelectedRouteKey(transaction)).toBe('route1'); + }); + + it('keeps the default route for an exact tie', () => { + const transaction = generateTransaction({comment: {customUnit: {routeDistanceMeters: 1000}}, routes: {...routes, route1: {...routes.route1, distance: 1000}}}); + expect(TransactionUtils.getSelectedRouteKey(transaction)).toBe('route0'); + }); + + it('falls back to the default route when there is nothing to derive the selection from', () => { + expect(TransactionUtils.getSelectedRouteKey(generateTransaction({routes}))).toBe('route0'); + expect(TransactionUtils.getSelectedRouteKey(generateTransaction({comment: {customUnit: {routeDistanceMeters: 1500}}}))).toBe('route0'); + expect(TransactionUtils.getSelectedRouteKey(generateTransaction({comment: {customUnit: {routeDistanceMeters: 1500}}, routes: {route0: {...routes.route0, distance: null}}}))).toBe( + 'route0', + ); + expect(TransactionUtils.getSelectedRouteKey(undefined)).toBe('route0'); + }); +}); + +describe('hasManualDistanceOverride', () => { + // 1 mi = 1609.344 m, so these are exactly 1 mi and 2 mi. + const routes = { + route0: {distance: 1609.344, geometry: {type: 'LineString' as const, coordinates: [[0, 0] as [number, number]]}}, + route1: {distance: 3218.688, geometry: {type: 'LineString' as const, coordinates: [[1, 1] as [number, number]]}}, + }; + const withQuantity = (quantity: number | null, comment: Transaction['comment'] = {}) => + generateTransaction({ + comment: {...comment, customUnit: {...comment?.customUnit, distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, quantity}}, + routes, + }); + + it('detects a quantity that does not match the selected route', () => { + expect(TransactionUtils.hasManualDistanceOverride(withQuantity(50))).toBe(true); + }); + + it('does not flag a quantity that matches the primary route', () => { + expect(TransactionUtils.hasManualDistanceOverride(withQuantity(1))).toBe(false); + }); + + it('does not flag a route-derived quantity when the re-fetched route distance drifted', () => { + // The expense was created from a 1 mi route (`routeDistanceMeters`), but the re-fetch returned 1.01 mi for + // the same route. The quantity still matches the creation-time distance, so it is not a manual override. + const transaction = generateTransaction({ + comment: {customUnit: {distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, quantity: 1, routeDistanceMeters: 1609.344}}, + routes: {route0: {distance: 1625.4, geometry: {type: 'LineString' as const, coordinates: [[0, 0] as [number, number]]}}}, + }); + expect(TransactionUtils.hasManualDistanceOverride(transaction)).toBe(false); + }); + + it('compares against the selected alternate route, not the primary one', () => { + // 2 mi is route1's own distance — an alternate route selection, not an override. + expect(TransactionUtils.hasManualDistanceOverride(withQuantity(2, {selectedRouteKey: 'route1'}))).toBe(false); + // 1 mi matches route0, but route1 is the selected route, so it is an override of it. + expect(TransactionUtils.hasManualDistanceOverride(withQuantity(1, {selectedRouteKey: 'route1'}))).toBe(true); + }); + + it('recovers the selection from routeDistanceMeters when the expense has no local pick', () => { + expect(TransactionUtils.hasManualDistanceOverride(withQuantity(2, {customUnit: {routeDistanceMeters: 3218.688}}))).toBe(false); + expect(TransactionUtils.hasManualDistanceOverride(withQuantity(50, {customUnit: {routeDistanceMeters: 3218.688}}))).toBe(true); + }); + + it('returns false when there is nothing to compare', () => { + expect(TransactionUtils.hasManualDistanceOverride(withQuantity(null))).toBe(false); + expect(TransactionUtils.hasManualDistanceOverride(generateTransaction({comment: {customUnit: {quantity: 50}}}))).toBe(false); + expect(TransactionUtils.hasManualDistanceOverride(undefined)).toBe(false); + }); +}); + +describe('getDistanceInMeters', () => { + const routes = { + route0: {distance: 1000, geometry: {type: 'LineString' as const, coordinates: [[0, 0] as [number, number]]}}, + route1: {distance: 1500, geometry: {type: 'LineString' as const, coordinates: [[1, 1] as [number, number]]}}, + }; + + it('returns the selected route distance', () => { + const transaction = generateTransaction({comment: {selectedRouteKey: 'route1'}, routes}); + expect(TransactionUtils.getDistanceInMeters(transaction, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES)).toBe(1500); + }); + + it('falls back to route0 when the selected route is no longer available', () => { + const transaction = generateTransaction({comment: {selectedRouteKey: 'route1'}, routes: {route0: routes.route0}}); + expect(TransactionUtils.getDistanceInMeters(transaction, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES)).toBe(1000); + }); +});