diff --git a/src/CONST/index.ts b/src/CONST/index.ts index d816013a9837..4cc852d23266 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7287,6 +7287,8 @@ const CONST = { TAG_FILTER_PAGE_SIZE: 200, EXITING_ANIMATION_DURATION: 200, ME: 'me', + // Null byte can't appear in a query, so it's safe to join query parts with it + QUERY_PARAMS_SEPARATOR: '\x00', /** How far the cursor may wander from where it last counted as moving over the advanced filter list and still count as resting */ HOVER_INTENT_REST_RADIUS_PX: 8, DATA_TYPES: { diff --git a/src/components/Search/SearchQueryProvider.tsx b/src/components/Search/SearchQueryProvider.tsx index dca69b63cd4d..5cc94bf40967 100644 --- a/src/components/Search/SearchQueryProvider.tsx +++ b/src/components/Search/SearchQueryProvider.tsx @@ -26,21 +26,34 @@ type SearchQueryProviderProps = { children: React.ReactNode; }; -function selectSearchQueryParam(state: NavigationState | undefined) { - return getSearchRootParamsFromRootState(state)?.q; +/** Joins `q` and `rawQuery` so they can't drift apart. Restoring only `q` would drop `rawFilterList` while keeping the same hash. */ +function selectSearchQueryParams(state: NavigationState | undefined) { + const searchRootParams = getSearchRootParamsFromRootState(state); + if (!searchRootParams) { + return undefined; + } + return `${searchRootParams.q}${CONST.SEARCH.QUERY_PARAMS_SEPARATOR}${searchRootParams.rawQuery ?? ''}`; } -function selectSearchRawQueryParam(state: NavigationState | undefined) { - return getSearchRootParamsFromRootState(state)?.rawQuery; +function splitSearchQueryParams(queryParams: string | undefined) { + if (queryParams === undefined) { + return {query: undefined, rawQuery: undefined}; + } + const separatorIndex = queryParams.indexOf(CONST.SEARCH.QUERY_PARAMS_SEPARATOR); + if (separatorIndex === -1) { + return {query: queryParams, rawQuery: undefined}; + } + const rawQuery = queryParams.slice(separatorIndex + CONST.SEARCH.QUERY_PARAMS_SEPARATOR.length); + return {query: queryParams.slice(0, separatorIndex), rawQuery: rawQuery || undefined}; } function SearchQueryProvider({children}: SearchQueryProviderProps) { const navigation = useNavigation(); // Extract only the primitive values we need from the resolved Search root route to avoid // re-renders from new object references returned by getSearchRootParamsFromRootState. - const queryParam = useRootNavigationState((state) => selectSearchQueryParam(state ?? navigation.getState())); - const rawQueryParam = useRootNavigationState((state) => selectSearchRawQueryParam(state ?? navigation.getState())); - const definedQueryParam = usePreviousDefined(queryParam) ?? buildSearchQueryString(); + const queryParams = useRootNavigationState((state) => selectSearchQueryParams(state ?? navigation.getState())); + const {query: queryParam, rawQuery: rawQueryParam} = splitSearchQueryParams(usePreviousDefined(queryParams)); + const definedQueryParam = queryParam ?? buildSearchQueryString(); const currentSearchQueryJSON = buildSearchQueryJSON(definedQueryParam, rawQueryParam); const shouldLoadCategoryData = currentSearchQueryJSON?.flatFilters.some((filter) => filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY) ?? false; useLoadSearchCategoryData({shouldLoad: shouldLoadCategoryData}); diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 8d5d87ae7166..d6233627588d 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -122,7 +122,7 @@ import {doesPersonalDetailExistSelector} from '@src/selectors/PersonalDetails'; import type {BillingGraceEndPeriod, ExportTemplate, Policy, Report, ReportAction, ReportNameValuePairs, SearchResults, Transaction, TransactionViolations} from '@src/types/onyx'; import type {SearchResultDataType} from '@src/types/onyx/SearchResults'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; -import {getEmptyObject} from '@src/types/utils/EmptyObject'; +import {getEmptyObject, isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -1929,6 +1929,13 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }); }, [selectedReports, currentSearchResults?.data, isTrackIntentUser, policies, selectedTransactions, rules]); + // Some matches haven't been loaded yet, so the client can't check whether they are movable + const hasUnloadedMatchingExpenses = !!currentSearchResults?.search?.hasMoreResults; + const isUnreportedOnlyQuery = useMemo(() => { + const statusFilter = queryJSON ? getFilterFromQuery(queryJSON, CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS) : undefined; + return !statusFilter?.isNegated && !!statusFilter?.value?.length && statusFilter.value.every((status) => status === CONST.SEARCH.STATUS.EXPENSE.UNREPORTED); + }, [queryJSON]); + const {headerButtonsOptions, dropdownButtonsOptions} = useMemo(() => { if ((selectedTransactionsKeys.length === 0 && !(isExpenseType && areAllMatchingItemsSelected)) || !hash) { const noOptions = CONST.EMPTY_ARRAY as unknown as Array>; @@ -2330,6 +2337,57 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { dropdownButtonsOptions: openExportOptionsDirectlyIfSoleAction(builtOptions), }); + // These checks only see the loaded page. The backend enforces the same rules on the full set. + const moveOwnerAccountIDs = new Set(); + let moveHasUnknownOwner = false; + for (const id of selectedTransactionsKeys) { + const transactionEntry = selectedTransactions[id]; + if (!transactionEntry) { + continue; + } + const ownerAccountID = + transactionEntry.ownerAccountID ?? + getReportOrDraftReport(transactionEntry.reportID, undefined, undefined, undefined, allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionEntry.reportID}`])?.ownerAccountID; + if (typeof ownerAccountID === 'number') { + moveOwnerAccountIDs.add(ownerAccountID); + } else { + moveHasUnknownOwner = true; + } + } + const moveHasMultipleOwners = moveOwnerAccountIDs.size > 1 || (moveHasUnknownOwner && (moveOwnerAccountIDs.size > 0 || selectedTransactionsKeys.length > 1)); + + // For selections across submitters, offer only Auto report when every expense has a resolved owner, + // is on a managed card, and does not depend on the destination workspace. Otherwise hide the flow. + const canAutoReportAcrossSubmitters = + moveOwnerAccountIDs.size > 1 && + !moveHasUnknownOwner && + selectedTransactionsKeys.every((id) => { + const transaction = selectedTransactions[id]?.transaction ?? allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`]; + if (!transaction || !isManagedCardTransaction(transaction)) { + return false; + } + return !(isPerDiemRequest(transaction) || isManualDistanceRequest(transaction) || isOdometerDistanceRequest(transaction)); + }); + + // `every` returns true for an empty selection, so check the length too. The destination page has nothing to move. + const canAllTransactionsBeMoved = selectedTransactionsKeys.length > 0 && selectedTransactionsKeys.every((id) => selectedTransactions[id].canChangeReport); + const canMoveExpenses = canAllTransactionsBeMoved && !isExpenseReportType && (!moveHasMultipleOwners || canAutoReportAcrossSubmitters); + + const moveExpensesOption: DropdownOption = { + text: translate('iou.moveExpenses'), + icon: expensifyIcons.DocumentMerge, + value: CONST.SEARCH.BULK_ACTION_TYPES.CHANGE_REPORT, + shouldCloseModalOnSelect: true, + onSelected: () => { + // A queued all-matching move would send a stale query, so ask the user to reconnect like export does + if (areAllMatchingItemsSelected && isOffline) { + setIsOfflineModalVisible(true); + return; + } + Navigation.navigate(ROUTES.MOVE_TRANSACTIONS_SEARCH_RHP.getRoute()); + }, + }; + const {shouldEnableBulkPayOption} = getPayOption(selectedReports, selectedTransactions, lastPaymentMethods, selectedReportIDs, personalPolicyID); const hasLoadedPayableReport = payableSelectedReports.length > 0 || selectedReports.length === 0; const shouldShowPayOption = areAllMatchingItemsSelected @@ -2389,11 +2447,23 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }; if (areAllMatchingItemsSelected) { - const selectAllOptions = shouldShowPayOption ? [payButtonOption, exportButtonOption] : [exportButtonOption]; + // Offer an all-matching move only when no rows are excluded and unloaded matches are unreported. + // The backend rejects the entire move if any matching expense is invalid + const isAllMatchingSelectionMovable = isEmptyObject(excludedTransactions) && (!hasUnloadedMatchingExpenses || isUnreportedOnlyQuery); + + const allMatchingOptions: Array> = []; + if (shouldShowPayOption) { + allMatchingOptions.push(payButtonOption); + } + allMatchingOptions.push(exportButtonOption); if (isExpenseReportSearch) { - selectAllOptions.push(downloadPDFOption); + allMatchingOptions.push(downloadPDFOption); } - return buildResult(selectAllOptions); + if (canMoveExpenses && isAllMatchingSelectionMovable) { + allMatchingOptions.push(moveExpensesOption); + } + + return buildResult(allMatchingOptions); } if (allSelectedAreDeleted) { @@ -2889,54 +2959,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { } } - const ownerAccountIDs = new Set(); - let hasUnknownOwner = false; - for (const id of selectedTransactionsKeys) { - const transactionEntry = selectedTransactions[id]; - if (!transactionEntry) { - continue; - } - const ownerAccountID = - transactionEntry.ownerAccountID ?? - getReportOrDraftReport(transactionEntry.reportID, undefined, undefined, undefined, allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionEntry.reportID}`])?.ownerAccountID; - if (typeof ownerAccountID === 'number') { - ownerAccountIDs.add(ownerAccountID); - } else { - hasUnknownOwner = true; - } - } - const hasMultipleOwners = ownerAccountIDs.size > 1 || (hasUnknownOwner && (ownerAccountIDs.size > 0 || selectedTransactionsKeys.length > 1)); - - const canAllTransactionsBeMoved = selectedTransactionsKeys.every((id) => selectedTransactions[id].canChangeReport); - - // Across submitters the only destination the App can offer is "Auto report". Every other mixed-owner selection - // stays hidden as before, so there is no entry into a screen that could only offer one submitter's reports to - // everybody else's expenses. Requirements: - // - every owner resolved, or the count below cannot tell one cardholder's bulk selection from a mixed one - // - every expense on a managed card, because the backend resolves each destination through the card; one - // expense without a card fails the whole request with "404 Card not found" - // - nothing whose validity depends on the destination workspace, which the backend picks: per diem rates and - // the map/GPS rules on manual and odometer distance can only be checked against a known workspace - // An expense we cannot read fails all three, so it withholds the flow rather than risking a rejected move. - const canAutoReportAcrossSubmitters = - ownerAccountIDs.size > 1 && - !hasUnknownOwner && - selectedTransactionsKeys.every((id) => { - const transaction = selectedTransactions[id]?.transaction ?? allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`]; - if (!transaction || !isManagedCardTransaction(transaction)) { - return false; - } - return !(isPerDiemRequest(transaction) || isManualDistanceRequest(transaction) || isOdometerDistanceRequest(transaction)); - }); - - if (canAllTransactionsBeMoved && !isExpenseReportType && (!hasMultipleOwners || canAutoReportAcrossSubmitters)) { - options.push({ - text: translate('iou.moveExpenses'), - icon: expensifyIcons.DocumentMerge, - value: CONST.SEARCH.BULK_ACTION_TYPES.CHANGE_REPORT, - shouldCloseModalOnSelect: true, - onSelected: () => Navigation.navigate(ROUTES.MOVE_TRANSACTIONS_SEARCH_RHP.getRoute()), - }); + if (canMoveExpenses) { + options.push(moveExpensesOption); } const firstTransactionKey = selectedTransactionsKeys.at(0); @@ -3125,6 +3149,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { noReportsShouldMarkAsDone, currentUserPersonalDetails.accountID, delegateAccountID, + hasUnloadedMatchingExpenses, + isUnreportedOnlyQuery, currentSearchQueryJSON, currentSearchResults?.search?.isLoading, shouldCalculateTotalsOnRefresh, diff --git a/src/libs/API/parameters/ChangeTransactionsReportParams.ts b/src/libs/API/parameters/ChangeTransactionsReportParams.ts index 0c668f1fc394..76a5049983c6 100644 --- a/src/libs/API/parameters/ChangeTransactionsReportParams.ts +++ b/src/libs/API/parameters/ChangeTransactionsReportParams.ts @@ -20,6 +20,8 @@ type ChangeTransactionsReportParams = { reportID: string; transactionIDToReportActionAndThreadData: string; // A map of transactionID to TransactionThreadInfo transactionIDToUpdatedCustomUnitRateID?: string; // A JSON map of transactionID to the new customUnitRateID (for distance expenses moving to a workspace with an invalid rate) + jsonQuery?: string; + hash?: number; }; export type {ChangeTransactionsReportParams, TransactionThreadInfo}; diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 59af4eb18d4e..28bbd12539f0 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -15,6 +15,7 @@ import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {toLocaleDigit} from '@libs/LocaleDigitUtils'; import {translateLocal} from '@libs/Localize'; +import Log from '@libs/Log'; import {buildOptimisticNextStep} from '@libs/NextStepUtils'; import * as NumberUtils from '@libs/NumberUtils'; import {rand64, roundToTwoDecimalPlaces} from '@libs/NumberUtils'; @@ -882,6 +883,8 @@ type ChangeTransactionsReportProps = { getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; isVendorMatchingBetaEnabled: boolean | undefined; + jsonQuery?: string; + hash?: number; }; function getChangeTransactionsReportOnyxData({ @@ -2076,6 +2079,60 @@ function getChangeTransactionsReportOnyxData({ } function changeTransactionsReport(props: ChangeTransactionsReportProps) { + const reportID = props.newReport?.reportID ?? CONST.REPORT.UNREPORTED_REPORT_ID; + + // Without a hash the move falls back to the explicit list and only the loaded page moves, so surface that + if (props.jsonQuery && props.hash === undefined) { + Log.warn('changeTransactionsReport: received an all-matching jsonQuery without a hash; falling back to the explicit transaction list, which only moves the loaded transactions.'); + } + + if (props.jsonQuery && props.hash !== undefined) { + // The backend moves the full query result while the client only has loaded transactions. + // Apply normal optimistic updates to loaded transactions, then send the request even if none are loaded. + const loadedTransactionsOnyxData = getChangeTransactionsReportOnyxData(props); + + const optimisticData = [...(loadedTransactionsOnyxData?.optimisticData ?? [])]; + const successData = [...(loadedTransactionsOnyxData?.successData ?? [])]; + const failureData = [...(loadedTransactionsOnyxData?.failureData ?? [])]; + + if (props.newReport) { + // Unloaded expenses are still moving server-side, so the destination stays pending for the whole request + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${props.newReport.reportID}`, + value: {pendingFields: {reportID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}}, + }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${props.newReport.reportID}`, + value: {pendingFields: {reportID: null}}, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${props.newReport.reportID}`, + value: {pendingFields: {reportID: null}}, + }); + } + + const transactionIDToUpdatedCustomUnitRateID = loadedTransactionsOnyxData?.transactionIDToUpdatedCustomUnitRateID ?? {}; + + const queryParameters: ChangeTransactionsReportParams = { + // Stays empty so the backend moves every matching expense from the query, not just the loaded page + transactionList: '', + reportID, + // Send the optimistic action and thread IDs so the backend reuses them instead of adding a second moved message + transactionIDToReportActionAndThreadData: JSON.stringify(loadedTransactionsOnyxData?.transactionIDToReportActionAndThreadData ?? {}), + ...(Object.keys(transactionIDToUpdatedCustomUnitRateID).length > 0 && { + transactionIDToUpdatedCustomUnitRateID: JSON.stringify(transactionIDToUpdatedCustomUnitRateID), + }), + jsonQuery: props.jsonQuery, + hash: props.hash, + }; + + API.write(WRITE_COMMANDS.CHANGE_TRANSACTIONS_REPORT, queryParameters, {optimisticData, successData, failureData}); + return; + } + const changeTransactionsReportOnyxData = getChangeTransactionsReportOnyxData(props); if (!changeTransactionsReportOnyxData) { return; @@ -2088,8 +2145,6 @@ function changeTransactionsReport(props: ChangeTransactionsReportProps) { return; } - const reportID = props.newReport?.reportID ?? CONST.REPORT.UNREPORTED_REPORT_ID; - const parameters: ChangeTransactionsReportParams = { transactionList: movedTransactionIDs.join(','), reportID, @@ -2099,6 +2154,8 @@ function changeTransactionsReport(props: ChangeTransactionsReportProps) { }), }; + // The all-matching path above already returned after its own API.write, so only one of the two writes ever runs + // eslint-disable-next-line rulesdir/no-multiple-api-calls API.write(WRITE_COMMANDS.CHANGE_TRANSACTIONS_REPORT, parameters, { optimisticData, successData, diff --git a/src/libs/getAllMatchingQueryParams.ts b/src/libs/getAllMatchingQueryParams.ts new file mode 100644 index 000000000000..3428682711c8 --- /dev/null +++ b/src/libs/getAllMatchingQueryParams.ts @@ -0,0 +1,32 @@ +import type {SearchQueryJSON, SelectedTransactions} from '@components/Search/types'; + +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +import Log from './Log'; +import {serializeQueryJSONForBackend} from './SearchQueryUtils'; + +type AllMatchingQueryParams = {jsonQuery?: string; hash?: number}; + +/** + * The backend needs the query and its hash to move every matching expense. Without them only the loaded page moves. + * + * @returns The query params, or an empty object when the query can't represent the selection. Callers then fall back + * to the explicit transaction list. + */ +function getAllMatchingQueryParams( + areAllMatchingItemsSelected: boolean, + excludedTransactions: SelectedTransactions | undefined, + currentSearchQueryJSON: Readonly | undefined, +): AllMatchingQueryParams { + // A query can't express unchecked rows, so sending it would move them back in + if (!areAllMatchingItemsSelected || !isEmptyObject(excludedTransactions ?? {})) { + return {}; + } + if (!currentSearchQueryJSON) { + Log.warn('[getAllMatchingQueryParams] All matching expenses are selected but the search query is unavailable; only the loaded expenses will be moved.'); + return {}; + } + return {jsonQuery: serializeQueryJSONForBackend(currentSearchQueryJSON), hash: currentSearchQueryJSON.hash}; +} + +export default getAllMatchingQueryParams; diff --git a/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx b/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx index 53c76746d16a..80732fcf8417 100644 --- a/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx +++ b/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx @@ -1,7 +1,7 @@ import ActivityIndicator from '@components/ActivityIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; -import {useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; +import {useSearchQueryContext, useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; import SelectionList from '@components/SelectionList'; import UserListItem from '@components/SelectionList/ListItem/UserListItem'; import type {ListItem} from '@components/SelectionList/types'; @@ -25,6 +25,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionsByID from '@hooks/useTransactionsByID'; +import getAllMatchingQueryParams from '@libs/getAllMatchingQueryParams'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import setNavigationActionToMicrotaskQueue from '@libs/Navigation/helpers/setNavigationActionToMicrotaskQueue'; @@ -66,8 +67,9 @@ function DynamicNewReportWorkspaceSelectionPage({route}: NewReportWorkspaceSelec const {isMovingExpenses} = route.params ?? {}; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.NEW_REPORT_WORKSPACE_SELECTION.path); const {isOffline} = useNetwork(); - const {selectedTransactions, selectedTransactionIDs} = useSearchSelectionContext(); + const {selectedTransactions, selectedTransactionIDs, areAllMatchingItemsSelected, excludedTransactions} = useSearchSelectionContext(); const {clearSelectedTransactions} = useSearchSelectionActions(); + const {currentSearchQueryJSON} = useSearchQueryContext(); const styles = useThemeStyles(); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const {translate, localeCompare} = useLocalize(); @@ -135,6 +137,9 @@ function DynamicNewReportWorkspaceSelectionPage({route}: NewReportWorkspaceSelec ...reports, [`${ONYXKEYS.COLLECTION.REPORT}${optimisticReport.reportID}`]: {...optimisticReport, transactionCount: 0, unheldNonReimbursableTotal: 0}, }; + // Do not send all-matching query params offline because reconnecting reevaluates the query and can include newly matching expenses. + // Move the explicit transaction list instead + const allMatchingQueryParams = isOffline ? {} : getAllMatchingQueryParams(areAllMatchingItemsSelected, excludedTransactions, currentSearchQueryJSON); setNavigationActionToMicrotaskQueue(() => { changeTransactionsReport({ isVendorMatchingBetaEnabled, @@ -156,6 +161,7 @@ function DynamicNewReportWorkspaceSelectionPage({route}: NewReportWorkspaceSelec delegateAccountID, getCurrencyDecimals, getCurrencySymbol, + ...allMatchingQueryParams, }); // eslint-disable-next-line rulesdir/no-default-id-values diff --git a/src/pages/Search/SearchTransactionsChangeReport.tsx b/src/pages/Search/SearchTransactionsChangeReport.tsx index 604437af7fd1..145ea72dda54 100644 --- a/src/pages/Search/SearchTransactionsChangeReport.tsx +++ b/src/pages/Search/SearchTransactionsChangeReport.tsx @@ -1,5 +1,6 @@ +import DecisionModal from '@components/DecisionModal'; import {usePersonalDetails, useSession} from '@components/OnyxListItemProvider'; -import {useSearchResultsContext, useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; +import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; import type {ListItem} from '@components/SelectionList/types'; import useChangeTransactionsReportReports from '@hooks/useChangeTransactionsReportReports'; @@ -8,13 +9,17 @@ import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useHasPerDiemTransactions from '@hooks/useHasPerDiemTransactions'; import useHydrateReportsFromSnapshot from '@hooks/useHydrateReportsFromSnapshot'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import {createNewReport} from '@libs/actions/Report'; import {autoReportTransactions, changeTransactionsReport} from '@libs/actions/Transaction'; +import getAllMatchingQueryParams from '@libs/getAllMatchingQueryParams'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import setNavigationActionToMicrotaskQueue from '@libs/Navigation/helpers/setNavigationActionToMicrotaskQueue'; @@ -37,7 +42,7 @@ import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {PersonalDetails, Transaction} from '@src/types/onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import React, {useMemo} from 'react'; +import React, {useMemo, useState} from 'react'; type TransactionGroupListItem = ListItem & { /** reportID of the report */ @@ -45,11 +50,27 @@ type TransactionGroupListItem = ListItem & { }; function SearchTransactionsChangeReport() { - const {selectedTransactions} = useSearchSelectionContext(); + const {selectedTransactions, areAllMatchingItemsSelected, excludedTransactions} = useSearchSelectionContext(); const delegateAccountID = useDelegateAccountID(); const {clearSelectedTransactions} = useSearchSelectionActions(); const {currentSearchResults} = useSearchResultsContext(); + const {currentSearchQueryJSON} = useSearchQueryContext(); const selectedTransactionsKeys = useMemo(() => Object.keys(selectedTransactions), [selectedTransactions]); + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply the correct modal type for the decision modal + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false); + + const allMatchingQueryParams = getAllMatchingQueryParams(areAllMatchingItemsSelected, excludedTransactions, currentSearchQueryJSON); + + /** + * Block offline all-matching moves because the backend reevaluates the query after reconnecting, + * which can include newly matching expenses. Recheck here because the connection may have dropped after the RHP opened + */ + const shouldBlockOfflineAllMatchingMove = () => isOffline && !!allMatchingQueryParams.jsonQuery; + // Search-selected transactions are not in COLLECTION.TRANSACTION — extract from `selectedTransactions` directly. const transactions = Object.values(selectedTransactions) .map((transactionItem) => transactionItem.transaction) @@ -185,6 +206,7 @@ function SearchTransactionsChangeReport() { delegateAccountID, getCurrencyDecimals, getCurrencySymbol, + ...allMatchingQueryParams, }); clearSelectedTransactions(); }); @@ -199,6 +221,10 @@ function SearchTransactionsChangeReport() { }); const createReport = () => { + if (shouldBlockOfflineAllMatchingMove()) { + setIsOfflineModalVisible(true); + return; + } if (shouldNavigateToUpgradePath && selectedTransactionsKeys.length > 0) { const firstTransactionID = selectedTransactionsKeys.at(0); if (firstTransactionID) { @@ -250,6 +276,10 @@ function SearchTransactionsChangeReport() { if (selectedTransactionsKeys.length === 0) { return; } + if (shouldBlockOfflineAllMatchingMove()) { + setIsOfflineModalVisible(true); + return; + } const destinationReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${item.value}`]; const policyTagList = item?.policyID ? allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${item.policyID}`] : {}; @@ -279,6 +309,7 @@ function SearchTransactionsChangeReport() { delegateAccountID, getCurrencyDecimals, getCurrencySymbol, + ...allMatchingQueryParams, }); Navigation.goBack(undefined, {afterTransition: clearSelectedTransactions}); }; @@ -295,6 +326,10 @@ function SearchTransactionsChangeReport() { if (selectedTransactionsKeys.length === 0) { return; } + if (shouldBlockOfflineAllMatchingMove()) { + setIsOfflineModalVisible(true); + return; + } const policyTagList = personalPolicyID ? allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${personalPolicyID}`] : {}; changeTransactionsReport({ isVendorMatchingBetaEnabled, @@ -314,32 +349,44 @@ function SearchTransactionsChangeReport() { delegateAccountID, getCurrencyDecimals, getCurrencySymbol, + ...allMatchingQueryParams, }); clearSelectedTransactions(); Navigation.goBack(); }; return ( - + <> + + setIsOfflineModalVisible(false)} + secondOptionText={translate('common.buttonConfirm')} + isVisible={isOfflineModalVisible} + onClose={() => setIsOfflineModalVisible(false)} + /> + ); } diff --git a/src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx b/src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx index 86ba8577c1ed..e4ba39cdfe1f 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx @@ -2,7 +2,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; -import {useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; +import {useSearchQueryContext, useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; import WorkspaceConfirmationForm from '@components/WorkspaceConfirmationForm'; import type {WorkspaceConfirmationSubmitFunctionParams} from '@components/WorkspaceConfirmationForm'; @@ -27,6 +27,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {createNewReport} from '@libs/actions/Report'; import {changeTransactionsReport, setTransactionReport} from '@libs/actions/Transaction'; import type CreateWorkspaceParams from '@libs/API/parameters/CreateWorkspaceParams'; +import getAllMatchingQueryParams from '@libs/getAllMatchingQueryParams'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import getPlatform from '@libs/getPlatform'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; @@ -98,8 +99,9 @@ function DynamicIOURequestStepUpgrade({ const createReportForCurrentUser = useCreateNewReport(); // Hooks for bulk move functionality - const {selectedTransactions} = useSearchSelectionContext(); + const {selectedTransactions, areAllMatchingItemsSelected, excludedTransactions} = useSearchSelectionContext(); const {clearSelectedTransactions} = useSearchSelectionActions(); + const {currentSearchQueryJSON} = useSearchQueryContext(); const selectedTransactionsKeys = useMemo(() => Object.keys(selectedTransactions), [selectedTransactions]); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); @@ -154,6 +156,10 @@ function DynamicIOURequestStepUpgrade({ [`${ONYXKEYS.COLLECTION.REPORT}${optimisticReport.reportID}`]: {...optimisticReport, transactionCount: 0, unheldNonReimbursableTotal: 0}, }; + // Do not send all-matching query params offline because reconnecting reevaluates the query and can include newly matching expenses. + // Move the explicit transaction list instead + const allMatchingQueryParams = isOffline ? {} : getAllMatchingQueryParams(areAllMatchingItemsSelected, excludedTransactions, currentSearchQueryJSON); + // Move ALL selected transactions to the new report changeTransactionsReport({ isVendorMatchingBetaEnabled, @@ -176,6 +182,7 @@ function DynamicIOURequestStepUpgrade({ delegateAccountID, getCurrencyDecimals, getCurrencySymbol, + ...allMatchingQueryParams, }); clearSelectedTransactions(); @@ -288,6 +295,10 @@ function DynamicIOURequestStepUpgrade({ getCurrencyDecimals, getCurrencySymbol, rules, + areAllMatchingItemsSelected, + currentSearchQueryJSON, + excludedTransactions, + isOffline, ]); const participant = transaction?.participants?.[0]; diff --git a/tests/unit/TransactionTest.ts b/tests/unit/TransactionTest.ts index 09f998e9ab47..39f78ff34619 100644 --- a/tests/unit/TransactionTest.ts +++ b/tests/unit/TransactionTest.ts @@ -2186,6 +2186,226 @@ describe('Transaction', () => { expect(updatedTransaction?.comment?.customUnit?.customUnitRateID).toBe(validRateID); }); + describe('when all matching items are selected (jsonQuery + hash)', () => { + const FAKE_JSON_QUERY = 'type:expense status:all'; + const FAKE_HASH = 123456; + + it('sends the search jsonQuery and hash with an empty transaction list instead of the explicit transactions', async () => { + const mockAPIWrite = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); + + const transaction = generateTransaction({reportID: FAKE_OLD_REPORT_ID}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); + const report = await getReportFromUseOnyx(FAKE_NEW_REPORT_ID); + const allTransactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`]: transaction, + }; + + changeTransactionsReport({ + isVendorMatchingBetaEnabled: false, + transactionIDs: [transaction.transactionID], + isASAPSubmitBetaEnabled: false, + accountID: CURRENT_USER_ID, + email: 'test@example.com', + newReport: report, + policy: undefined, + allTransactions, + policyTagList: undefined, + transactionViolations: {}, + reports: undefined, + isTrackIntentUser: false, + jsonQuery: FAKE_JSON_QUERY, + hash: FAKE_HASH, + }); + await waitForBatchedUpdates(); + + expect(mockAPIWrite).toHaveBeenCalled(); + + const parameters = mockAPIWrite.mock.calls.at(0)?.[1]; + + expect(parameters).toEqual( + expect.objectContaining({ + reportID: FAKE_NEW_REPORT_ID, + // The list stays empty so the backend moves every matching expense from the query + transactionList: '', + jsonQuery: FAKE_JSON_QUERY, + hash: FAKE_HASH, + }), + ); + + // The loaded transaction's optimistic action and thread IDs go out so the backend reuses them + const transactionData = parseJSONRecord(readProperty(parameters, 'transactionIDToReportActionAndThreadData')); + expect(hasDefinedProperty(transactionData, transaction.transactionID)).toBe(true); + + mockAPIWrite.mockRestore(); + }); + + it('falls back to the unreported report ID when removing all matching expenses from a report', async () => { + const mockAPIWrite = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); + + const transaction = generateTransaction({reportID: FAKE_OLD_REPORT_ID}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); + const allTransactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`]: transaction, + }; + + changeTransactionsReport({ + isVendorMatchingBetaEnabled: false, + transactionIDs: [transaction.transactionID], + isASAPSubmitBetaEnabled: false, + accountID: CURRENT_USER_ID, + email: 'test@example.com', + newReport: undefined, + policy: undefined, + allTransactions, + policyTagList: undefined, + transactionViolations: {}, + reports: undefined, + isTrackIntentUser: false, + jsonQuery: FAKE_JSON_QUERY, + hash: FAKE_HASH, + }); + await waitForBatchedUpdates(); + + expect(mockAPIWrite).toHaveBeenCalled(); + + const parameters = mockAPIWrite.mock.calls.at(0)?.[1]; + + expect(parameters).toEqual( + expect.objectContaining({ + reportID: CONST.REPORT.UNREPORTED_REPORT_ID, + transactionList: '', + jsonQuery: FAKE_JSON_QUERY, + hash: FAKE_HASH, + }), + ); + + mockAPIWrite.mockRestore(); + }); + + it('optimistically flags the destination report as pending and clears it after the request succeeds', async () => { + const destinationReport = { + ...createRandomReport(7, undefined), + ownerAccountID: CURRENT_USER_ID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + const destinationReportKey = `${ONYXKEYS.COLLECTION.REPORT}${destinationReport.reportID}` as const; + + mockFetch.pause(); + try { + await Onyx.merge(destinationReportKey, destinationReport); + + changeTransactionsReport({ + isVendorMatchingBetaEnabled: false, + transactionIDs: [], + isASAPSubmitBetaEnabled: false, + accountID: CURRENT_USER_ID, + email: 'test@example.com', + newReport: destinationReport, + policy: undefined, + allTransactions: {}, + policyTagList: undefined, + transactionViolations: {}, + reports: undefined, + isTrackIntentUser: false, + jsonQuery: FAKE_JSON_QUERY, + hash: FAKE_HASH, + }); + await waitForBatchedUpdates(); + + // The destination stays pending while the request is in flight + const pendingReport = await getOnyxValue(destinationReportKey); + expect(pendingReport?.pendingFields?.reportID).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + } finally { + await mockFetch.resume(); + } + await waitForBatchedUpdates(); + + // The pending flag clears once the request resolves + const resolvedReport = await getOnyxValue(destinationReportKey); + expect(resolvedReport?.pendingFields?.reportID).toBeFalsy(); + }); + + it('optimistically moves the loaded transactions so their rows leave the list before the server answers', async () => { + const transaction = generateTransaction({reportID: FAKE_OLD_REPORT_ID}); + const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}` as const; + await Onyx.merge(transactionKey, transaction); + const report = await getReportFromUseOnyx(FAKE_NEW_REPORT_ID); + const allTransactions = {[transactionKey]: transaction}; + + // Pause the request instead of mocking API.write so the optimistic data really reaches Onyx + mockFetch.pause(); + try { + changeTransactionsReport({ + isVendorMatchingBetaEnabled: false, + transactionIDs: [transaction.transactionID], + isASAPSubmitBetaEnabled: false, + accountID: CURRENT_USER_ID, + email: 'test@example.com', + newReport: report, + policy: undefined, + allTransactions, + policyTagList: undefined, + transactionViolations: {}, + reports: undefined, + isTrackIntentUser: false, + jsonQuery: FAKE_JSON_QUERY, + hash: FAKE_HASH, + }); + await waitForBatchedUpdates(); + + // The all-matching move applies the same optimistic update as a per-page move + const movedTransaction = await getOnyxValue(transactionKey); + expect(movedTransaction?.reportID).toBe(FAKE_NEW_REPORT_ID); + } finally { + await mockFetch.resume(); + } + }); + + it('uses the normal explicit-transaction path when a hash is passed without a jsonQuery', async () => { + const mockAPIWrite = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); + + const transaction = generateTransaction({reportID: FAKE_OLD_REPORT_ID}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); + const report = await getReportFromUseOnyx(FAKE_NEW_REPORT_ID); + const allTransactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`]: transaction, + }; + + changeTransactionsReport({ + isVendorMatchingBetaEnabled: false, + transactionIDs: [transaction.transactionID], + isASAPSubmitBetaEnabled: false, + accountID: CURRENT_USER_ID, + email: 'test@example.com', + newReport: report, + policy: undefined, + allTransactions, + policyTagList: undefined, + transactionViolations: {}, + reports: undefined, + isTrackIntentUser: false, + jsonQuery: undefined, + hash: FAKE_HASH, + }); + await waitForBatchedUpdates(); + + expect(mockAPIWrite).toHaveBeenCalled(); + + const parameters = mockAPIWrite.mock.calls.at(0)?.[1]; + + // Without a jsonQuery the explicit list goes out and no all-matching params leak through + expect(parameters).toEqual( + expect.objectContaining({ + transactionList: transaction.transactionID, + }), + ); + expect(parameters).not.toHaveProperty('jsonQuery'); + expect(parameters).not.toHaveProperty('hash'); + + mockAPIWrite.mockRestore(); + }); + }); + it('should not create MOVED_TRANSACTION action when moving expenses into a Draft report', async () => { const mockAPIWrite = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve());