diff --git a/src/CONST/index.ts b/src/CONST/index.ts index dfb026b5592e4..2a57cfa7c4c3a 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5806,6 +5806,7 @@ const CONST = { RECEIPT_GENERATED_WITH_AI: 'receiptGeneratedWithAI', OVER_TRIP_LIMIT: 'overTripLimit', COMPANY_CARD_REQUIRED: 'companyCardRequired', + NO_ROUTE: 'noRoute', MISSING_ATTENDEES: 'missingAttendees', }, RTER_VIOLATION_TYPES: { diff --git a/src/components/DistanceRequest/DistanceRequestRenderItem.tsx b/src/components/DistanceRequest/DistanceRequestRenderItem.tsx index 7364cd9adf4be..98870d7d928ae 100644 --- a/src/components/DistanceRequest/DistanceRequestRenderItem.tsx +++ b/src/components/DistanceRequest/DistanceRequestRenderItem.tsx @@ -1,9 +1,10 @@ import React from 'react'; -import * as Expensicons from '@components/Icon/Expensicons'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; +import {isWaypointNullIsland} from '@libs/TransactionUtils'; +import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type {WaypointCollection} from '@src/types/onyx/Transaction'; @@ -32,7 +33,7 @@ type DistanceRequestProps = { function DistanceRequestRenderItem({waypoints, item = '', onSecondaryInteraction, getIndex, isActive = false, onPress = () => {}, disabled = false}: DistanceRequestProps) { const theme = useTheme(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Location']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Location', 'DotIndicatorUnfilled', 'DotIndicator', 'DragHandles']); const {translate} = useLocalize(); const numberOfWaypoints = Object.keys(waypoints ?? {}).length; const lastWaypointIndex = numberOfWaypoints - 1; @@ -42,24 +43,25 @@ function DistanceRequestRenderItem({waypoints, item = '', onSecondaryInteraction let waypointIcon; if (index === 0) { descriptionKey += 'start'; - waypointIcon = Expensicons.DotIndicatorUnfilled; + waypointIcon = expensifyIcons.DotIndicatorUnfilled; } else if (index === lastWaypointIndex) { descriptionKey += 'stop'; waypointIcon = expensifyIcons.Location; } else { descriptionKey += 'stop'; - waypointIcon = Expensicons.DotIndicator; + waypointIcon = expensifyIcons.DotIndicator; } const waypoint = waypoints?.[`waypoint${index}`] ?? {}; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const title = waypoint.name || waypoint.address; + const errorText = isWaypointNullIsland(waypoint) ? translate('violations.noRoute') : undefined; return ( ); } diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index a28f245497e61..6036931d4da16 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -613,6 +613,8 @@ function MoneyRequestView({ ), ); }} + brickRoadIndicator={getErrorForField('waypoints') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={getErrorForField('waypoints')} copyValue={distanceCopyValue} copyable={!!distanceCopyValue} /> diff --git a/src/hooks/useViolations.ts b/src/hooks/useViolations.ts index acd96ca0987a8..bbad1d83c2faa 100644 --- a/src/hooks/useViolations.ts +++ b/src/hooks/useViolations.ts @@ -6,7 +6,7 @@ import type {TransactionViolation, ViolationName} from '@src/types/onyx'; /** * Names of Fields where violations can occur. */ -const validationFields = ['amount', 'billable', 'category', 'comment', 'date', 'merchant', 'receipt', 'tag', 'tax', 'attendees', 'customUnitRateID', 'none'] as const; +const validationFields = ['amount', 'billable', 'category', 'comment', 'date', 'merchant', 'receipt', 'tag', 'tax', 'attendees', 'customUnitRateID', 'waypoints', 'none'] as const; type ViolationField = TupleToUnion; @@ -61,6 +61,7 @@ const violationNameToField: Record 'none', receiptGeneratedWithAI: () => 'receipt', companyCardRequired: () => 'none', + noRoute: () => 'waypoints', }; type ViolationsMap = Map; diff --git a/src/languages/de.ts b/src/languages/de.ts index dcb5ecbe73c43..0ec4166f0989b 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -280,7 +280,6 @@ const translations: TranslationDeepObject = { searchWithThreeDots: 'Suchen …', next: 'Weiter', previous: 'Zurück', - // @context Navigation button that returns the user to the previous screen. Should be interpreted as a UI action label. goBack: 'Zurück', create: 'Erstellen', add: 'Hinzufügen', @@ -7366,6 +7365,7 @@ Fordere Spesendetails wie Belege und Beschreibungen an, lege Limits und Standard hold: 'Diese Ausgabe wurde zurückgestellt', resolvedDuplicates: 'Duplikat behoben', companyCardRequired: 'Firmenkartenkäufe erforderlich', + noRoute: 'Bitte wählen Sie eine gültige Adresse aus', }, reportViolations: { [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: ({fieldName}: RequiredFieldParams) => `${fieldName} ist erforderlich`, diff --git a/src/languages/en.ts b/src/languages/en.ts index b8f6b2991e0e3..ec028d8918f62 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7299,6 +7299,7 @@ const translations = { hold: 'This expense was put on hold', resolvedDuplicates: 'resolved the duplicate', companyCardRequired: 'Company card purchases required', + noRoute: 'Please select a valid address', }, reportViolations: { [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: ({fieldName}: RequiredFieldParams) => `${fieldName} is required`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 7a93665054c41..d8cbd50e73d91 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7451,6 +7451,7 @@ ${amount} para ${merchant} - ${date}`, hold: 'Este gasto está retenido', resolvedDuplicates: 'resolvió el duplicado', companyCardRequired: 'Se requieren compras con la tarjeta de la empresa.', + noRoute: 'Por favor, selecciona una dirección válida', }, reportViolations: { [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: ({fieldName}) => `${fieldName} es obligatorio`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index e0ed752099d33..307c341f65bd6 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -280,7 +280,6 @@ const translations: TranslationDeepObject = { searchWithThreeDots: 'Rechercher...', next: 'Suivant', previous: 'Précédent', - // @context Navigation button that returns the user to the previous screen. Should be interpreted as a UI action label. goBack: 'Retour', create: 'Créer', add: 'Ajouter', @@ -7376,6 +7375,7 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin hold: 'Cette dépense a été mise en attente', resolvedDuplicates: 'a résolu le doublon', companyCardRequired: 'Achats avec carte d’entreprise requis', + noRoute: 'Veuillez sélectionner une adresse valide', }, reportViolations: { [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: ({fieldName}: RequiredFieldParams) => `${fieldName} est requis`, diff --git a/src/languages/it.ts b/src/languages/it.ts index f6713dafc3235..042ecad874e42 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -280,8 +280,7 @@ const translations: TranslationDeepObject = { searchWithThreeDots: 'Cerca...', next: 'Avanti', previous: 'Precedente', - // @context Navigation button that returns the user to the previous screen. Should be interpreted as a UI action label. - goBack: 'Torna indietro', + goBack: 'Indietro', create: 'Crea', add: 'Aggiungi', resend: 'Reinvia', @@ -7352,6 +7351,7 @@ Richiedi dettagli di spesa come ricevute e descrizioni, imposta limiti e valori hold: 'Questa spesa è stata messa in sospeso', resolvedDuplicates: 'ha risolto il duplicato', companyCardRequired: 'Acquisti con carta aziendale obbligatori', + noRoute: 'Seleziona un indirizzo valido', }, reportViolations: { [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: ({fieldName}: RequiredFieldParams) => `${fieldName} è obbligatorio`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 1e0f1465b5e01..c88dfead79cab 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -280,7 +280,6 @@ const translations: TranslationDeepObject = { searchWithThreeDots: '検索...', next: '次へ', previous: '前へ', - // @context Navigation button that returns the user to the previous screen. Should be interpreted as a UI action label. goBack: '戻る', create: '作成', add: '追加', @@ -7296,6 +7295,7 @@ ${reportName} hold: 'この経費は保留になっています', resolvedDuplicates: '重複を解決しました', companyCardRequired: '法人カードでの購入が必須', + noRoute: '有効な住所を選択してください', }, reportViolations: { [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: ({fieldName}: RequiredFieldParams) => `${fieldName} は必須です`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index ff93ef78706ef..0701af7186f26 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -280,7 +280,6 @@ const translations: TranslationDeepObject = { searchWithThreeDots: 'Zoeken...', next: 'Volgende', previous: 'Vorige', - // @context Navigation button that returns the user to the previous screen. Should be interpreted as a UI action label. goBack: 'Ga terug', create: 'Maken', add: 'Toevoegen', @@ -7340,6 +7339,7 @@ Vraag verplichte uitgavedetails zoals bonnetjes en beschrijvingen, stel limieten hold: 'Deze uitgave is in de wacht gezet', resolvedDuplicates: 'het duplicaat opgelost', companyCardRequired: 'Aankopen met bedrijfskaart verplicht', + noRoute: 'Selecteer een geldig adres', }, reportViolations: { [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: ({fieldName}: RequiredFieldParams) => `${fieldName} is verplicht`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index dee93876af4f1..150f9e7a33837 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -280,7 +280,6 @@ const translations: TranslationDeepObject = { searchWithThreeDots: 'Szukaj...', next: 'Dalej', previous: 'Wstecz', - // @context Navigation button that returns the user to the previous screen. Should be interpreted as a UI action label. goBack: 'Wróć', create: 'Utwórz', add: 'Dodaj', @@ -7325,6 +7324,7 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i hold: 'Ten wydatek został wstrzymany', resolvedDuplicates: 'rozwiązano duplikat', companyCardRequired: 'Wymagane zakupy kartą firmową', + noRoute: 'Wybierz prawidłowy adres', }, reportViolations: { [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: ({fieldName}: RequiredFieldParams) => `Pole ${fieldName} jest wymagane`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 5ee2a3e7e70b4..d7fc8da1fbfb6 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -280,7 +280,6 @@ const translations: TranslationDeepObject = { searchWithThreeDots: 'Pesquisar...', next: 'Próximo', previous: 'Anterior', - // @context Navigation button that returns the user to the previous screen. Should be interpreted as a UI action label. goBack: 'Voltar', create: 'Criar', add: 'Adicionar', @@ -7327,6 +7326,7 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe hold: 'Esta despesa foi colocada em espera', resolvedDuplicates: 'duplicata resolvida', companyCardRequired: 'Compras com cartão da empresa obrigatórias', + noRoute: 'Selecione um endereço válido', }, reportViolations: { [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: ({fieldName}: RequiredFieldParams) => `${fieldName} é obrigatório`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index dd16d15864724..8558c8c3fec59 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -280,7 +280,6 @@ const translations: TranslationDeepObject = { searchWithThreeDots: '搜索…', next: '下一步', previous: '上一步', - // @context Navigation button that returns the user to the previous screen. Should be interpreted as a UI action label. goBack: '返回', create: '创建', add: '添加', @@ -7175,6 +7174,7 @@ ${reportName} hold: '此报销已被搁置', resolvedDuplicates: '已解决重复项', companyCardRequired: '需要公司卡消费', + noRoute: '请选择一个有效的地址', }, reportViolations: { [CONST.REPORT_VIOLATIONS.FIELD_REQUIRED]: ({fieldName}: RequiredFieldParams) => `${fieldName}为必填项`, diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 4c3970061d106..aa7a9b8eda6a3 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -181,6 +181,10 @@ function getDistanceForDisplay( return translate('iou.fieldPending'); } + if (!distanceInMeters) { + return ''; + } + const distanceInUnits = getRoundedDistanceInUnits(distanceInMeters, unit); if (useShortFormUnit) { return `${distanceInUnits} ${unit}`; @@ -221,6 +225,10 @@ function getDistanceMerchant( return translate('iou.fieldPending'); } + if (!distanceInMeters) { + return ''; + } + const distanceInUnits = getDistanceForDisplay(hasRoute, distanceInMeters, unit, rate, translate, true); const ratePerUnit = getRateForDisplay(unit, rate, currency, translate, toLocaleDigit, undefined, true); diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index 75bd7dd8e16f4..a2d56162c8f1c 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -19,7 +19,7 @@ import { isReportApproved, isSettled, } from './ReportUtils'; -import {hasSmartScanFailedViolation, isPending, isScanning} from './TransactionUtils'; +import {hasSmartScanFailedOrNoRouteViolation, isPending, isScanning} from './TransactionUtils'; function canSubmit( report: Report, @@ -46,7 +46,7 @@ function canSubmit( const isAnyReceiptBeingScanned = transactions?.some((transaction) => isScanning(transaction)); - if (transactions?.some((transaction) => hasSmartScanFailedViolation(transaction, violations, currentUserEmail, currentUserAccountID, report, policy))) { + if (transactions?.some((transaction) => hasSmartScanFailedOrNoRouteViolation(transaction, violations, currentUserEmail, currentUserAccountID, report, policy))) { return false; } diff --git a/src/libs/ReportPrimaryActionUtils.ts b/src/libs/ReportPrimaryActionUtils.ts index 4722e735515d5..4d8368cde5429 100644 --- a/src/libs/ReportPrimaryActionUtils.ts +++ b/src/libs/ReportPrimaryActionUtils.ts @@ -42,7 +42,7 @@ import { allHavePendingRTERViolation, getTransactionViolations, hasPendingRTERViolation as hasPendingRTERViolationTransactionUtils, - hasSmartScanFailedViolation, + hasSmartScanFailedOrNoRouteViolation, isDuplicate, isOnHold as isOnHoldTransactionUtils, isPending, @@ -115,7 +115,7 @@ function isSubmitAction( } if (violations && currentUserEmail && currentUserAccountID !== undefined) { - if (reportTransactions.some((transaction) => hasSmartScanFailedViolation(transaction, violations, currentUserEmail, currentUserAccountID, report, policy))) { + if (reportTransactions.some((transaction) => hasSmartScanFailedOrNoRouteViolation(transaction, violations, currentUserEmail, currentUserAccountID, report, policy))) { return false; } } diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index b35082415cd6c..1b8570466e272 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -68,7 +68,7 @@ import { allHavePendingRTERViolation, getOriginalTransactionWithSplitInfo, hasReceipt as hasReceiptTransactionUtils, - hasSmartScanFailedViolation, + hasSmartScanFailedOrNoRouteViolation, isDistanceRequest as isDistanceRequestTransactionUtils, isDuplicate, isManagedCardTransaction as isManagedCardTransactionTransactionUtils, @@ -195,7 +195,7 @@ function isSubmitAction({ } if (violations && currentUserLogin && currentUserAccountID !== undefined) { - if (reportTransactions.some((transaction) => hasSmartScanFailedViolation(transaction, violations, currentUserLogin, currentUserAccountID, report, policy))) { + if (reportTransactions.some((transaction) => hasSmartScanFailedOrNoRouteViolation(transaction, violations, currentUserLogin, currentUserAccountID, report, policy))) { return false; } } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 3f5b2f9cdbd11..6ff3cc7cdef03 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -300,6 +300,7 @@ import { getConvertedAmount, getCurrency, getDescription, + getDistanceInMeters, getFormattedCreated, getFormattedPostedDate, getMCCGroup, @@ -326,6 +327,7 @@ import { isExpensifyCardTransaction, isFetchingWaypointsFromServer, isManualDistanceRequest as isManualDistanceRequestTransactionUtils, + isMapDistanceRequest, isOnHold as isOnHoldTransactionUtils, isPayAtEndExpense, isPending, @@ -5047,6 +5049,12 @@ function getTransactionReportName({ return translateLocal('iou.fieldPending'); } + // The unit does not matter as we are only interested in whether the distance is zero or not + if (isMapDistanceRequest(transaction) && !getDistanceInMeters(transaction, CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS)) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return translateLocal('violations.noRoute'); + } + if (isSentMoneyReportAction(reportAction)) { return getIOUReportActionDisplayMessage(reportAction as ReportAction, transaction); } diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index e8a0011a2da7d..1bc6eee4671db 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -1675,6 +1675,10 @@ function waypointHasValidAddress(waypoint: RecentWaypoint | Waypoint): boolean { return !!waypoint?.address?.trim(); } +function isWaypointNullIsland(waypoint: RecentWaypoint | Waypoint): boolean { + return waypoint.lat === 0 && waypoint.lng === 0; +} + /** * Converts the key of a waypoint to its index */ @@ -1713,6 +1717,11 @@ function getValidWaypoints(waypoints: WaypointCollection | undefined, reArrangeI return acc; } + // Exclude null island + if (isWaypointNullIsland(currentWaypoint)) { + return acc; + } + // Check for adjacent waypoints with the same address or coordinate const previousCoordinate: Coordinate | undefined = previousWaypoint?.lng && previousWaypoint?.lat ? [previousWaypoint.lng, previousWaypoint.lat] : undefined; const currentCoordinate: Coordinate | undefined = currentWaypoint.lng && currentWaypoint.lat ? [currentWaypoint.lng, currentWaypoint.lat] : undefined; @@ -2557,9 +2566,9 @@ function isExpenseUnreported(transaction?: Transaction): transaction is Unreport } /** - * Check if there is a smartscan failed violation for the transaction. + * Check if there is a smartscan failed or no route violation for the transaction. */ -function hasSmartScanFailedViolation( +function hasSmartScanFailedOrNoRouteViolation( transaction: Transaction, transactionViolations: OnyxCollection | undefined, currentUserEmail: string, @@ -2568,7 +2577,7 @@ function hasSmartScanFailedViolation( policy: OnyxEntry, ): boolean { const violations = getTransactionViolations(transaction, transactionViolations, currentUserEmail, currentUserAccountID, report, policy); - return !!violations?.some((violation) => violation.name === CONST.VIOLATIONS.SMARTSCAN_FAILED); + return !!violations?.some((violation) => violation.name === CONST.VIOLATIONS.SMARTSCAN_FAILED || violation.name === CONST.VIOLATIONS.NO_ROUTE); } /** @@ -2668,12 +2677,13 @@ export { hasPendingUI, getWaypointIndex, waypointHasValidAddress, + isWaypointNullIsland, getRecentTransactions, hasReservationList, hasViolation, hasDuplicateTransactions, hasBrokenConnectionViolation, - hasSmartScanFailedViolation, + hasSmartScanFailedOrNoRouteViolation, shouldShowBrokenConnectionViolation, shouldShowBrokenConnectionViolationForMultipleTransactions, hasNoticeTypeViolation, diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index b1b2fccd6fa16..1580494df13f0 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -651,6 +651,8 @@ const ViolationsUtils = { }); case CONST.VIOLATIONS.RECEIPT_GENERATED_WITH_AI: return translate('violations.receiptGeneratedWithAI'); + case CONST.VIOLATIONS.NO_ROUTE: + return translate('violations.noRoute'); default: // The interpreter should never get here because the switch cases should be exhaustive. // If typescript is showing an error on the assertion below it means the switch statement is out of diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 086c8b635334d..cd624d76a707f 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -4458,7 +4458,8 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U isPaidGroupPolicy(policy) && !isInvoice && updatedTransaction && - (hasModifiedTag || + (hasPendingWaypoints || + hasModifiedTag || hasModifiedCategory || hasModifiedComment || hasModifiedMerchant || @@ -4481,6 +4482,9 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U hasModifiedCategory && transactionChanges.category === '' ? optimisticViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY) : optimisticViolations; + if (hasPendingWaypoints) { + optimisticViolations = optimisticViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.NO_ROUTE); + } const violationsOnyxData = ViolationsUtils.getViolationsOnyxData( updatedTransaction, @@ -5133,9 +5137,9 @@ type UpdateMoneyRequestDistanceParams = { waypoints?: WaypointCollection; distance?: number; routes?: Routes; - policy?: OnyxEntry; - policyTagList?: OnyxEntry; - policyCategories?: OnyxEntry; + policy: OnyxEntry; + policyTagList: OnyxEntry; + policyCategories: OnyxEntry; transactionBackup: OnyxEntry; currentUserAccountIDParam: number; currentUserEmailParam: string; @@ -5152,9 +5156,9 @@ function updateMoneyRequestDistance({ waypoints, distance, routes = undefined, - policy = {} as OnyxTypes.Policy, - policyTagList = {}, - policyCategories = {}, + policy, + policyTagList, + policyCategories, transactionBackup, currentUserAccountIDParam, currentUserEmailParam, diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 7db998adf50a7..c58ce0ac542c3 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -1,8 +1,6 @@ import {getUnixTime} from 'date-fns'; -import {deepEqual} from 'fast-equals'; import lodashClone from 'lodash/clone'; -import lodashHas from 'lodash/has'; -import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; import type { @@ -54,7 +52,7 @@ import type { } from '@src/types/onyx'; import type {OriginalMessageIOU, OriginalMessageModifiedExpense} from '@src/types/onyx/OriginalMessage'; import type {OnyxData} from '@src/types/onyx/Request'; -import type {WaypointCollection} from '@src/types/onyx/Transaction'; +import type {Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; import type TransactionState from '@src/types/utils/TransactionStateType'; import {getPolicyTagsData} from './Policy/Tag'; import {getCurrentUserAccountID} from './Report'; @@ -124,10 +122,23 @@ type SaveWaypointProps = { }; function saveWaypoint({transactionID, index, waypoint, isDraft = false, recentWaypointsList = []}: SaveWaypointProps) { + // Saving a waypoint should completely overwrite the existing one at the given index (if any). + // Onyx merge performs noop on undefined fields. Thus we should fallback to null so the existing fields are cleared. + const waypointOnyxUpdate: Required> | null = waypoint + ? { + name: waypoint.name ?? null, + address: waypoint.address ?? null, + lat: waypoint.lat ?? null, + lng: waypoint.lng ?? null, + keyForList: waypoint.keyForList ?? null, + pendingAction: waypoint.pendingAction ?? null, + } + : null; + Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { comment: { waypoints: { - [`waypoint${index}`]: waypoint, + [`waypoint${index}`]: waypointOnyxUpdate, }, customUnit: { quantity: null, @@ -153,16 +164,9 @@ function saveWaypoint({transactionID, index, waypoint, isDraft = false, recentWa }, }); - // You can save offline waypoints without verifying the address (we will geocode it on the backend) - // We're going to prevent saving those addresses in the recent waypoints though since they could be invalid addresses - // However, in the backend once we verify the address, we will save the waypoint in the recent waypoints NVP - if (!lodashHas(waypoint, 'lat') || !lodashHas(waypoint, 'lng')) { - return; - } - // If current location is used, we would want to avoid saving it as a recent waypoint. This prevents the 'Your Location' // text from showing up in the address search suggestions - if (deepEqual(waypoint?.address, CONST.YOUR_LOCATION_TEXT)) { + if (waypoint?.address === CONST.YOUR_LOCATION_TEXT) { return; } const recentWaypointAlreadyExists = recentWaypointsList.find((recentWaypoint) => recentWaypoint?.address === waypoint?.address); @@ -364,9 +368,33 @@ function getRoute(transactionID: string, waypoints: WaypointCollection, routeTyp * which will replace the existing ones. */ function updateWaypoints(transactionID: string, waypoints: WaypointCollection, isDraft = false): Promise { + // Updating waypoints should completely overwrite the existing ones. + // Onyx merge performs noop on undefined fields. Thus we should fallback to null so the existing fields are cleared. + const waypointsOnyxUpdate = Object.keys(waypoints).reduce( + (acc, key) => { + const waypoint = waypoints[key]; + acc[key] = { + name: waypoint.name ?? null, + address: waypoint.address ?? null, + lat: waypoint.lat ?? null, + lng: waypoint.lng ?? null, + city: 'city' in waypoint ? (waypoint.city ?? null) : null, + state: 'state' in waypoint ? (waypoint.state ?? null) : null, + zipCode: 'zipCode' in waypoint ? (waypoint.zipCode ?? null) : null, + country: 'country' in waypoint ? (waypoint.country ?? null) : null, + street: 'street' in waypoint ? (waypoint.street ?? null) : null, + street2: 'street2' in waypoint ? (waypoint.street2 ?? null) : null, + pendingAction: 'pendingAction' in waypoint ? (waypoint.pendingAction ?? null) : null, + keyForList: waypoint.keyForList ?? null, + }; + return acc; + }, + {} as Record>>, + ); + return Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { comment: { - waypoints, + waypoints: waypointsOnyxUpdate, customUnit: { quantity: null, }, diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index eb4907f646f4b..b85c90b00dd3d 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -53,7 +53,7 @@ import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; import {getPolicy, isPaidGroupPolicy} from '@libs/PolicyUtils'; import {getPolicyExpenseChat, isArchivedReport, isPolicyExpenseChat as isPolicyExpenseChatUtil} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; -import {getDistanceInMeters, getRateID, getRequestType, getValidWaypoints, hasRoute, isCustomUnitRateIDForP2P} from '@libs/TransactionUtils'; +import {getDistanceInMeters, getRateID, getRequestType, getValidWaypoints, hasRoute, isCustomUnitRateIDForP2P, isWaypointNullIsland} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -91,6 +91,8 @@ function IOURequestStepDistance({ const [transactionBackup] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionID}`, {canBeMissing: true}); const policy = usePolicy(report?.policyID); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy?.id}`, {canBeMissing: true}); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policy?.id}`, {canBeMissing: true}); const personalPolicy = usePersonalPolicy(); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false}); const defaultExpensePolicy = useDefaultExpensePolicy(); @@ -143,6 +145,7 @@ function IOURequestStepDistance({ const nonEmptyWaypointsCount = useMemo(() => Object.keys(waypoints).filter((key) => !isWaypointEmpty(waypoints[key])).length, [waypoints]); const currentUserAccountIDParam = currentUserPersonalDetails.accountID; const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; + const isWaypointsNullIslandError = useMemo(() => Object.values(waypoints).some(isWaypointNullIsland), [waypoints]); const duplicateWaypointsError = useMemo( () => nonEmptyWaypointsCount >= 2 && Object.keys(validatedWaypoints).length !== nonEmptyWaypointsCount, [nonEmptyWaypointsCount, validatedWaypoints], @@ -471,6 +474,9 @@ function IOURequestStepDistance({ if (hasRouteError) { return getLatestErrorField(transaction, 'route'); } + if (isWaypointsNullIslandError) { + return {isWaypointsNullIslandError: `${translate('common.please')} ${translate('common.fixTheErrors')} ${translate('common.inTheFormBeforeContinuing')}.`} as Errors; + } if (duplicateWaypointsError) { return {duplicateWaypointsError: translate('iou.error.duplicateWaypointsErrorMessage')} as Errors; } @@ -539,6 +545,8 @@ function IOURequestStepDistance({ waypoints, ...(hasRouteChanged ? {routes: transaction?.routes} : {}), policy, + policyTagList: policyTags, + policyCategories, transactionBackup, currentUserAccountIDParam, currentUserEmailParam, @@ -568,6 +576,8 @@ function IOURequestStepDistance({ navigateBack, parentReport, policy, + policyTags, + policyCategories, currentUserAccountIDParam, currentUserEmailParam, isASAPSubmitBetaEnabled, diff --git a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx index ebdab0a721ec1..42908f3592d47 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx @@ -82,6 +82,8 @@ function IOURequestStepDistanceManual({ const [selectedTab, selectedTabResult] = useOnyx(`${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.DISTANCE_REQUEST_TYPE}`, {canBeMissing: true}); const isLoadingSelectedTab = isLoadingOnyxValue(selectedTabResult); const policy = usePolicy(report?.policyID); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy?.id}`, {canBeMissing: true}); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policy?.id}`, {canBeMissing: true}); const personalPolicy = usePersonalPolicy(); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false}); const defaultExpensePolicy = useDefaultExpensePolicy(); @@ -180,6 +182,8 @@ function IOURequestStepDistanceManual({ // Not required for manual distance request transactionBackup: undefined, policy, + policyTagList: policyTags, + policyCategories, currentUserAccountIDParam, currentUserEmailParam, isASAPSubmitBetaEnabled, @@ -313,6 +317,8 @@ function IOURequestStepDistanceManual({ transaction, parentReport, policy, + policyTags, + policyCategories, currentUserAccountIDParam, currentUserEmailParam, isASAPSubmitBetaEnabled, diff --git a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx index d10cb0630d45b..dcf12878ffb7c 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx @@ -53,7 +53,7 @@ import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; import {getPolicy, isPaidGroupPolicy} from '@libs/PolicyUtils'; import {getPolicyExpenseChat, isArchivedReport, isPolicyExpenseChat as isPolicyExpenseChatUtil} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; -import {getDistanceInMeters, getRateID, getRequestType, getValidWaypoints, hasRoute, isCustomUnitRateIDForP2P} from '@libs/TransactionUtils'; +import {getDistanceInMeters, getRateID, getRequestType, getValidWaypoints, hasRoute, isCustomUnitRateIDForP2P, isWaypointNullIsland} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -91,6 +91,8 @@ function IOURequestStepDistanceMap({ const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`, {canBeMissing: true}); const [transactionBackup] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionID}`, {canBeMissing: true}); const policy = usePolicy(report?.policyID); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy?.id}`, {canBeMissing: true}); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policy?.id}`, {canBeMissing: true}); const personalPolicy = usePersonalPolicy(); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false}); const defaultExpensePolicy = useDefaultExpensePolicy(); @@ -140,7 +142,7 @@ function IOURequestStepDistanceMap({ return isEmpty(waypointWithoutKey); }; const nonEmptyWaypointsCount = useMemo(() => Object.keys(waypoints).filter((key) => !isWaypointEmpty(waypoints[key])).length, [waypoints]); - + const isWaypointsNullIslandError = useMemo(() => Object.values(waypoints).some(isWaypointNullIsland), [waypoints]); const duplicateWaypointsError = useMemo( () => nonEmptyWaypointsCount >= 2 && Object.keys(validatedWaypoints).length !== nonEmptyWaypointsCount, [nonEmptyWaypointsCount, validatedWaypoints], @@ -471,6 +473,9 @@ function IOURequestStepDistanceMap({ if (hasRouteError) { return getLatestErrorField(transaction, 'route'); } + if (isWaypointsNullIslandError) { + return {isWaypointsNullIslandError: `${translate('common.please')} ${translate('common.fixTheErrors')} ${translate('common.inTheFormBeforeContinuing')}.`} as Errors; + } if (duplicateWaypointsError) { return {duplicateWaypointsError: translate('iou.error.duplicateWaypointsErrorMessage')} as Errors; } @@ -539,6 +544,8 @@ function IOURequestStepDistanceMap({ waypoints, ...(hasRouteChanged ? {routes: transaction?.routes} : {}), policy, + policyTagList: policyTags, + policyCategories, transactionBackup, currentUserAccountIDParam, currentUserEmailParam, @@ -566,6 +573,8 @@ function IOURequestStepDistanceMap({ navigateToNextStep, parentReport, policy, + policyTags, + policyCategories, report, transaction?.routes, transaction?.transactionID, diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index a40afc8e479d1..d343130b842aa 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -105,6 +105,8 @@ function IOURequestStepDistanceOdometer({ const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`, {canBeMissing: true}); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`, {canBeMissing: true}); const policy = usePolicy(report?.policyID); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy?.id}`, {canBeMissing: true}); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policy?.id}`, {canBeMissing: true}); const personalPolicy = usePersonalPolicy(); const defaultExpensePolicy = useDefaultExpensePolicy(); @@ -339,6 +341,8 @@ function IOURequestStepDistanceOdometer({ // Not required for odometer distance request transactionBackup: undefined, policy, + policyTagList: policyTags, + policyCategories, currentUserAccountIDParam, currentUserEmailParam, isASAPSubmitBetaEnabled: false, diff --git a/src/pages/iou/request/step/IOURequestStepWaypoint.tsx b/src/pages/iou/request/step/IOURequestStepWaypoint.tsx index 0f2f0b7f1c697..755ab23b2a7c6 100644 --- a/src/pages/iou/request/step/IOURequestStepWaypoint.tsx +++ b/src/pages/iou/request/step/IOURequestStepWaypoint.tsx @@ -140,10 +140,10 @@ function IOURequestStepWaypoint({ // Therefore, we're going to save the waypoint as just the address, and the lat/long will be filled in on the backend if (isOffline && waypointValue) { const waypoint = { - address: waypointValue ?? '', - name: values.name ?? '', - lat: values.lat ?? 0, - lng: values.lng ?? 0, + address: waypointValue, + name: values.name, + lat: values.lat, + lng: values.lng, keyForList: `${(values.name ?? 'waypoint') as string}_${Date.now()}`, }; save(waypoint); @@ -175,10 +175,10 @@ function IOURequestStepWaypoint({ const selectWaypoint = (values: Waypoint) => { const waypoint = { - lat: values.lat ?? 0, - lng: values.lng ?? 0, - address: values.address ?? '', - name: values.name ?? '', + lat: values.lat, + lng: values.lng, + address: values.address, + name: values.name, keyForList: `${values.name ?? 'waypoint'}_${Date.now()}`, }; diff --git a/tests/unit/TransactionTest.ts b/tests/unit/TransactionTest.ts index 23599ffeed1c7..fbdb70d78e89e 100644 --- a/tests/unit/TransactionTest.ts +++ b/tests/unit/TransactionTest.ts @@ -992,20 +992,6 @@ describe('Transaction', () => { expect(updatedRecentWaypoints?.[0]?.address).toBe('123 Main St'); }); - it('should not save waypoint if missing lat/lng', async () => { - const transactionID = 'txn2'; - const index = '1'; - const waypoint: RecentWaypoint = { - address: 'No LatLng', - }; - const recentWaypointsList: RecentWaypoint[] = []; - saveWaypoint({transactionID, index, waypoint, isDraft: false, recentWaypointsList}); - await waitForBatchedUpdates(); - - const updatedRecentWaypoints = await OnyxUtils.get(ONYXKEYS.NVP_RECENT_WAYPOINTS); - expect(updatedRecentWaypoints?.length ?? 0).toBe(0); - }); - it('should not save waypoint if address is YOUR_LOCATION_TEXT', async () => { const transactionID = 'txn3'; const index = '2';