diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 759affeb3c0d3..344b4fe8e8710 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -580,6 +580,8 @@ const ONYXKEYS = { /** List of transaction thread IDs used when navigating to prev/next transaction when viewing it in RHP */ TRANSACTION_THREAD_NAVIGATION_REPORT_IDS: 'transactionThreadNavigationReportIDs', + REPORT_NAVIGATION_LAST_SEARCH_QUERY: 'ReportNavigationLastSearchQuery', + /** Timestamp of the last login on iOS */ NVP_LAST_ECASH_IOS_LOGIN: 'nvp_lastECashIOSLogin', NVP_LAST_IPHONE_LOGIN: 'nvp_lastiPhoneLogin', @@ -1267,6 +1269,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_LAST_ECASH_IOS_LOGIN]: string; [ONYXKEYS.NVP_LAST_ECASH_ANDROID_LOGIN]: string; [ONYXKEYS.NVP_LAST_IPHONE_LOGIN]: string; + [ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY]: OnyxTypes.LastSearchParams; [ONYXKEYS.NVP_LAST_ANDROID_LOGIN]: string; [ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_REPORT_IDS]: string[]; [ONYXKEYS.NVP_INTEGRATION_SERVER_EXPORT_TEMPLATES]: OnyxTypes.ExportTemplate[]; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index aa2eb9cbb8d52..74dff42e47155 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -130,6 +130,7 @@ import MoneyReportHeaderStatusBar from './MoneyReportHeaderStatusBar'; import MoneyReportHeaderStatusBarSkeleton from './MoneyReportHeaderStatusBarSkeleton'; import type {MoneyRequestHeaderStatusBarProps} from './MoneyRequestHeaderStatusBar'; import MoneyRequestHeaderStatusBar from './MoneyRequestHeaderStatusBar'; +import MoneyRequestReportNavigation from './MoneyRequestReportView/MoneyRequestReportNavigation'; import type {PopoverMenuItem} from './PopoverMenu'; import type {ActionHandledType} from './ProcessMoneyReportHoldMenu'; import ProcessMoneyReportHoldMenu from './ProcessMoneyReportHoldMenu'; @@ -401,6 +402,8 @@ function MoneyReportHeader({ const isReportInRHP = route.name === SCREENS.SEARCH.REPORT_RHP; const shouldDisplaySearchRouter = !isReportInRHP || isSmallScreenWidth; + const isReportInSearch = route.name === SCREENS.SEARCH.MONEY_REQUEST_REPORT; + const existingB2BInvoiceReport = useParticipantsInvoiceReport(activePolicyID, CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS, chatReport?.policyID); const confirmPayment = useCallback( (type?: PaymentMethodType | undefined, payAsBusiness?: boolean, methodID?: number, paymentMethod?: PaymentMethod) => { @@ -456,14 +459,6 @@ function MoneyReportHeader({ } else { startApprovedAnimation(); approveMoneyRequest(moneyRequestReport, true); - if (currentSearchQueryJSON) { - search({ - searchKey: currentSearchKey, - shouldCalculateTotals: true, - offset: 0, - queryJSON: currentSearchQueryJSON, - }); - } } }; @@ -1251,44 +1246,6 @@ function MoneyReportHeader({ ); - const hasOtherItems = - (shouldShowNextStep && !!optimisticNextStep?.message?.length) || (shouldShowNextStep && !optimisticNextStep && !!isLoadingInitialReportActions && !isOffline) || !!statusBarProps; - - const moreContentUnfiltered = [ - shouldShowSelectedTransactionsButton && shouldDisplayNarrowVersion && ( - - null} - options={selectedTransactionsOptions} - customText={translate('workspace.common.selected', {count: selectedTransactionIDs.length})} - isSplitButton={false} - shouldAlwaysShowDropdownMenu - wrapperStyle={styles.w100} - /> - - ), - shouldShowNextStep && !!optimisticNextStep?.message?.length && ( - - ), - shouldShowNextStep && !optimisticNextStep && !!isLoadingInitialReportActions && !isOffline && , - !!statusBarProps && ( - - ), - ]; - const moreContent = moreContentUnfiltered.filter(Boolean); - const isMoreContentShown = moreContent.length > 0; - const shouldAddGapToContents = moreContent.length > 1; - return ( )} - {shouldDisplayNarrowVersion && !shouldShowSelectedTransactionsButton && ( - - {!!primaryAction && {primaryActionsImplementation[primaryAction]}} - {!!applicableSecondaryActions.length && KYCMoreDropdown} - - )} - {isMoreContentShown && {moreContent}} + {shouldDisplayNarrowVersion && + (shouldShowSelectedTransactionsButton ? ( + + null} + options={selectedTransactionsOptions} + customText={translate('workspace.common.selected', {count: selectedTransactionIDs.length})} + isSplitButton={false} + shouldAlwaysShowDropdownMenu + wrapperStyle={styles.w100} + /> + + ) : ( + + {!!primaryAction && {primaryActionsImplementation[primaryAction]}} + {!!applicableSecondaryActions.length && KYCMoreDropdown} + + ))} + + + + {shouldShowNextStep && !!optimisticNextStep?.message?.length && } + {shouldShowNextStep && !optimisticNextStep && !!isLoadingInitialReportActions && !isOffline && } + {!!statusBarProps && ( + + )} + + {isReportInSearch && ( + + )} + {isHoldMenuVisible && requestType !== undefined && ( diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx new file mode 100644 index 0000000000000..06bb6b6911e97 --- /dev/null +++ b/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx @@ -0,0 +1,135 @@ +import React, {useEffect} from 'react'; +import {View} from 'react-native'; +import PrevNextButtons from '@components/PrevNextButtons'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {selectArchivedReportsIdSet, selectFilteredReportActions} from '@libs/ReportUtils'; +import {getSections, getSortedSections} from '@libs/SearchUIUtils'; +import Navigation from '@navigation/Navigation'; +import {saveLastSearchParams} from '@userActions/ReportNavigation'; +import {search} from '@userActions/Search'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +type MoneyRequestReportNavigationProps = { + reportID?: string; + shouldDisplayNarrowVersion: boolean; +}; + +function MoneyRequestReportNavigation({reportID, shouldDisplayNarrowVersion}: MoneyRequestReportNavigationProps) { + const [lastSearchQuery] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY, {canBeMissing: true}); + const [currentSearchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${lastSearchQuery?.queryJSON?.hash}`, {canBeMissing: true}); + const [accountID] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: false, selector: (s) => s?.accountID}); + + const {localeCompare, formatPhoneNumber} = useLocalize(); + + const [exportReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { + canEvict: false, + canBeMissing: true, + selector: selectFilteredReportActions, + }); + + const [archivedReportsIdSet = new Set()] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, { + canBeMissing: true, + selector: selectArchivedReportsIdSet, + }); + + const {type, status, sortBy, sortOrder, groupBy} = lastSearchQuery?.queryJSON ?? {}; + let results: Array = []; + if (!!type && !!groupBy && !!currentSearchResults?.data && !!currentSearchResults?.search) { + const searchData = getSections(type, currentSearchResults.data, accountID, formatPhoneNumber, groupBy, exportReportActions, lastSearchQuery?.searchKey, archivedReportsIdSet); + results = getSortedSections(type, status ?? '', searchData, localeCompare, sortBy, sortOrder, groupBy).map((value) => value.reportID); + } + const allReports = results; + + const currentIndex = allReports.indexOf(reportID); + const allReportsCount = lastSearchQuery?.previousLengthOfResults ?? 0; + + const hideNextButton = !lastSearchQuery?.hasMoreResults && currentIndex === allReports.length - 1; + const hidePrevButton = currentIndex === 0; + const styles = useThemeStyles(); + const shouldDisplayNavigationArrows = allReports && allReports.length > 1 && currentIndex !== -1 && !!lastSearchQuery?.queryJSON; + + useEffect(() => { + if (!lastSearchQuery?.queryJSON) { + return; + } + + if (lastSearchQuery.allowPostSearchRecount) { + saveLastSearchParams({ + ...lastSearchQuery, + allowPostSearchRecount: false, + previousLengthOfResults: allReports.length, + }); + return; + } + + if (currentIndex < allReportsCount - 1) { + return; + } + + saveLastSearchParams({ + ...lastSearchQuery, + previousLengthOfResults: allReports.length, + }); + }, [currentIndex, allReportsCount, allReports.length, lastSearchQuery?.queryJSON, lastSearchQuery]); + + const goToReportId = (reportId?: string) => { + if (!reportId) { + return; + } + Navigation.setParams({ + reportID: reportId, + }); + }; + + const goToNextReport = () => { + if (currentIndex === -1 || allReports.length === 0 || !lastSearchQuery?.queryJSON) { + return; + } + const threshold = Math.min(allReports.length * 0.75, allReports.length - 2); + + if (currentIndex + 1 >= threshold && lastSearchQuery?.hasMoreResults) { + const newOffset = (lastSearchQuery.offset ?? 0) + CONST.SEARCH.RESULTS_PAGE_SIZE; + search({ + queryJSON: lastSearchQuery.queryJSON, + offset: newOffset, + prevReportsLength: allReports.length, + shouldCalculateTotals: false, + searchKey: lastSearchQuery.searchKey, + }); + } + + const nextIndex = (currentIndex + 1) % allReports.length; + goToReportId(allReports.at(nextIndex)); + }; + + const goToPrevReport = () => { + if (currentIndex === -1 || allReports.length === 0) { + return; + } + + const prevIndex = (currentIndex - 1) % allReports.length; + goToReportId(allReports.at(prevIndex)); + }; + + return ( + shouldDisplayNavigationArrows && ( + + {!shouldDisplayNarrowVersion && {`${currentIndex + 1} of ${allReportsCount}`}} + + + ) + ); +} + +MoneyRequestReportNavigation.displayName = 'MoneyRequestReportNavigation'; + +export default MoneyRequestReportNavigation; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx index 8ea11ad41bf9f..4946ee1f3991b 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx @@ -47,6 +47,9 @@ type MoneyRequestReportViewProps = { /** Whether Report footer (that includes Composer) should be displayed */ shouldDisplayReportFooter: boolean; + /** Whether we should wait for the report to sync */ + shouldWaitForReportSync: boolean; + /** The `backTo` route that should be used when clicking back button */ backToRoute: Route | undefined; }; @@ -86,7 +89,7 @@ function getParentReportAction(parentReportActions: OnyxEntry reportActions.some((action) => isSentMoneyReportAction(action)), [reportActions]); - const newTransactions = useNewTransactions(reportMetadata?.hasOnceLoadedReportActions, transactions); - + const newTransactions = useNewTransactions(reportMetadata?.hasOnceLoadedReportActions, shouldWaitForReportSync ? [] : transactions); const [parentReportAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(report?.parentReportID)}`, { canEvict: false, canBeMissing: true, @@ -172,7 +174,7 @@ function MoneyRequestReportView({report, policy, reportMetadata, shouldDisplayRe [backToRoute, isLoadingInitialReportActions, isTransactionThreadView, parentReportAction, policy, report, reportActions, transactionThreadReportID], ); - if (!!(isLoadingInitialReportActions && reportActions.length === 0 && !isOffline) || shouldWaitForTransactions) { + if (!!(isLoadingInitialReportActions && reportActions.length === 0 && !isOffline) || shouldWaitForTransactions || shouldWaitForReportSync) { return ; } diff --git a/src/components/PrevNextButtons.tsx b/src/components/PrevNextButtons.tsx index 23f950d30f524..cd92b59f14ede 100644 --- a/src/components/PrevNextButtons.tsx +++ b/src/components/PrevNextButtons.tsx @@ -33,7 +33,7 @@ function PrevNextButtons({isPrevButtonDisabled, isNextButtonDisabled, onNext, on accessibilityRole={CONST.ROLE.BUTTON} accessibilityLabel={CONST.ROLE.BUTTON} disabled={isPrevButtonDisabled} - style={[styles.h10, styles.mr1, styles.alignItemsCenter, styles.justifyContentCenter]} + style={[styles.h7, styles.mr1, styles.alignItemsCenter, styles.justifyContentCenter]} onPress={onPrevious} > @@ -50,7 +50,7 @@ function PrevNextButtons({isPrevButtonDisabled, isNextButtonDisabled, onNext, on accessibilityRole={CONST.ROLE.BUTTON} accessibilityLabel={CONST.ROLE.BUTTON} disabled={isNextButtonDisabled} - style={[styles.h10, styles.alignItemsCenter, styles.justifyContentCenter]} + style={[styles.h7, styles.alignItemsCenter, styles.justifyContentCenter]} onPress={onNext} > diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 52066eeb44af0..58f80dbc8a7ee 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -3,7 +3,6 @@ import {accountIDSelector} from '@selectors/Session'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; -import type {OnyxCollection} from 'react-native-onyx'; import Animated, {FadeIn, FadeOut, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import FullPageErrorView from '@components/BlockingViews/FullPageErrorView'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; @@ -26,9 +25,10 @@ import Log from '@libs/Log'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNavigation/types'; import Performance from '@libs/Performance'; -import {getIOUActionForTransactionID, isExportIntegrationAction, isIntegrationMessageAction} from '@libs/ReportActionsUtils'; -import {canEditFieldOfMoneyRequest, isArchivedReport} from '@libs/ReportUtils'; +import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; +import {canEditFieldOfMoneyRequest, selectArchivedReportsIdSet, selectFilteredReportActions} from '@libs/ReportUtils'; import {buildCannedSearchQuery, buildSearchQueryJSON, buildSearchQueryString} from '@libs/SearchQueryUtils'; +import type {SearchKey} from '@libs/SearchUIUtils'; import { createAndOpenSearchTransactionThread, getColumnsToShow, @@ -49,7 +49,6 @@ import { shouldShowEmptyState, shouldShowYear as shouldShowYearUtil, } from '@libs/SearchUIUtils'; -import type {ArchivedReportsIDSet, SearchKey} from '@libs/SearchUIUtils'; import {isOnHold, isTransactionPendingDelete} from '@libs/TransactionUtils'; import Navigation, {navigationRef} from '@navigation/Navigation'; import type {SearchFullscreenNavigatorParamList} from '@navigation/types'; @@ -59,7 +58,7 @@ import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import type {OutstandingReportsByPolicyIDDerivedValue, ReportAction, ReportActions, ReportNameValuePairs} from '@src/types/onyx'; +import type {OutstandingReportsByPolicyIDDerivedValue, ReportAction} from '@src/types/onyx'; import type SearchResults from '@src/types/onyx/SearchResults'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import arraysEqual from '@src/utils/arraysEqual'; @@ -189,33 +188,6 @@ function prepareTransactionsList( }; } -const archivedReportsSelector = (all: OnyxCollection): ArchivedReportsIDSet => { - const ids = new Set(); - if (!all) { - return ids; - } - - const prefixLength = ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS.length; - for (const [key, value] of Object.entries(all)) { - if (isArchivedReport(value)) { - const reportID = key.slice(prefixLength); - ids.add(reportID); - } - } - return ids; -}; - -const exportReportActionsSelector = (allReportActions: OnyxCollection) => { - return Object.fromEntries( - Object.entries(allReportActions ?? {}).map(([reportID, reportActionsGroup]) => { - const filteredReportActions = Object.values(reportActionsGroup ?? {}).filter( - (action) => isExportIntegrationAction(action as ReportAction) || isIntegrationMessageAction(action as ReportAction), - ); - return [reportID, filteredReportActions]; - }), - ); -}; - function Search({queryJSON, searchResults, onSearchListScroll, contentContainerStyle, handleSearch, isMobileSelectionModeEnabled, onSortPressedCallback}: SearchProps) { const {isOffline} = useNetwork(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -251,13 +223,13 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS const [archivedReportsIdSet = new Set()] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, { canBeMissing: true, - selector: archivedReportsSelector, + selector: selectArchivedReportsIdSet, }); const [exportReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { canEvict: false, canBeMissing: true, - selector: exportReportActionsSelector, + selector: selectFilteredReportActions, }); const {defaultCardFeed} = useCardFeedsForDisplay(); @@ -365,20 +337,6 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSmallScreenWidth, selectedTransactions, isMobileSelectionModeEnabled]); - useEffect(() => { - const focusedRoute = findFocusedRoute(navigationRef.getRootState()); - const isMigratedModalDisplayed = focusedRoute?.name === NAVIGATORS.MIGRATED_USER_MODAL_NAVIGATOR || focusedRoute?.name === SCREENS.MIGRATED_USER_WELCOME_MODAL.ROOT; - - if ((!isFocused && !isMigratedModalDisplayed) || isOffline) { - return; - } - - handleSearch({queryJSON, searchKey, offset, shouldCalculateTotals}); - // We don't need to run the effect on change of isFocused. - // eslint-disable-next-line react-compiler/react-compiler - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [handleSearch, isOffline, offset, queryJSON, searchKey, shouldCalculateTotals]); - useEffect(() => { openSearch(); }, []); @@ -403,19 +361,20 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS const shouldShowLoadingMoreItems = !shouldShowLoadingState && searchResults?.search?.isLoading && searchResults?.search?.offset > 0; const prevIsSearchResultEmpty = usePrevious(isSearchResultsEmpty); - const data = useMemo(() => { + const [data, dataLength] = useMemo(() => { if (searchResults === undefined || !isDataLoaded) { - return []; + return [[], 0]; } // Group-by option cannot be used for chats or tasks const isChat = type === CONST.SEARCH.DATA_TYPES.CHAT; const isTask = type === CONST.SEARCH.DATA_TYPES.TASK; if (groupBy && (isChat || isTask)) { - return []; + return [[], 0]; } - return getSections(type, searchResults.data, accountID, formatPhoneNumber, groupBy, exportReportActions, searchKey, archivedReportsIdSet, queryJSON); + const data1 = getSections(type, searchResults.data, accountID, formatPhoneNumber, groupBy, exportReportActions, searchKey, archivedReportsIdSet, queryJSON); + return [data1, data1.length]; }, [searchKey, exportReportActions, groupBy, isDataLoaded, searchResults, type, archivedReportsIdSet, formatPhoneNumber, accountID, queryJSON]); useEffect(() => { @@ -423,6 +382,21 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS setShouldShowFiltersBarLoading(shouldShowLoadingState && lastSearchType !== type); }, [lastSearchType, setShouldShowFiltersBarLoading, shouldShowLoadingState, type]); + useEffect(() => { + const focusedRoute = findFocusedRoute(navigationRef.getRootState()); + const isMigratedModalDisplayed = focusedRoute?.name === NAVIGATORS.MIGRATED_USER_MODAL_NAVIGATOR || focusedRoute?.name === SCREENS.MIGRATED_USER_WELCOME_MODAL.ROOT; + + if ((!isFocused && !isMigratedModalDisplayed) || isOffline) { + return; + } + + handleSearch({queryJSON, searchKey, offset, shouldCalculateTotals, prevReportsLength: dataLength}); + + // We don't need to run the effect on change of isFocused. + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [handleSearch, isOffline, offset, queryJSON, searchKey, shouldCalculateTotals]); + // When new data load, selectedTransactions is updated in next effect. We use this flag to whether selection is updated const isRefreshingSelection = useRef(false); diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 1d44fa7902862..e03fb612fa245 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -202,6 +202,7 @@ type SearchParams = { queryJSON: SearchQueryJSON; searchKey: SearchKey | undefined; offset: number; + prevReportsLength?: number; shouldCalculateTotals: boolean; }; diff --git a/src/libs/API/index.ts b/src/libs/API/index.ts index ecec81a52df53..afe9be1b35f2d 100644 --- a/src/libs/API/index.ts +++ b/src/libs/API/index.ts @@ -311,4 +311,4 @@ function paginate { + const rootState = navigationRef.getRootState() as State | undefined; + if (!rootState) { + return false; + } + + const lastRootRoute = rootState.routes.at(-1); + const lastNestedRoute = lastRootRoute?.state?.routes?.at(-1); + return lastNestedRoute?.name === SCREENS.SEARCH.MONEY_REQUEST_REPORT; +}; + +export default isOnSearchMoneyRequestReportPage; diff --git a/src/libs/Navigation/helpers/isRHPOnSearchMoneyRequestReportPage.ts b/src/libs/Navigation/helpers/isRHPOnSearchMoneyRequestReportPage.ts new file mode 100644 index 0000000000000..d91a3201c5c6d --- /dev/null +++ b/src/libs/Navigation/helpers/isRHPOnSearchMoneyRequestReportPage.ts @@ -0,0 +1,16 @@ +import {navigationRef} from '@libs/Navigation/Navigation'; +import type {RootNavigatorParamList, State} from '@libs/Navigation/types'; +import SCREENS from '@src/SCREENS'; + +const isRHPOnSearchMoneyRequestReportPage = (): boolean => { + const rootState = navigationRef.getRootState() as State | undefined; + if (!rootState) { + return false; + } + + const lastRootRoute = rootState.routes.at(-2); + const lastNestedRoute = lastRootRoute?.state?.routes?.at(-1); + return lastNestedRoute?.name === SCREENS.SEARCH.MONEY_REQUEST_REPORT; +}; + +export default isRHPOnSearchMoneyRequestReportPage; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 4b1a52cedb8fe..712a9d400ac96 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -226,6 +226,7 @@ import { wasActionTakenByCurrentUser, } from './ReportActionsUtils'; import type {LastVisibleMessage} from './ReportActionsUtils'; +import type {ArchivedReportsIDSet} from './SearchUIUtils'; import {getSession} from './SessionUtils'; import {shouldRestrictUserBillableActions} from './SubscriptionUtils'; import { @@ -11576,6 +11577,39 @@ function getMoneyReportPreviewName(action: ReportAction, iouReport: OnyxEntry> | null | undefined): ArchivedReportsIDSet { + const archivedIDs = new Set(); + if (!all) { + return archivedIDs; + } + + const prefixLen = ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS.length; + + for (const [key, value] of Object.entries(all)) { + if (isArchivedReport(value)) { + archivedIDs.add(key.slice(prefixLen)); + } + } + + return archivedIDs; +} + +function selectFilteredReportActions( + reportActions: Record> | undefined> | null | undefined, +): Record | undefined { + if (!reportActions) { + return {}; + } + + return Object.fromEntries( + Object.entries(reportActions).map(([reportId, actionsGroup]) => { + const actions = Object.values(actionsGroup ?? {}); + const filteredActions = actions.filter((action): action is ReportAction => isExportIntegrationAction(action) || isIntegrationMessageAction(action)); + return [reportId, filteredActions]; + }), + ); +} + /** * Returns the necessary reportAction onyx data to indicate that the transaction has been rejected optimistically * @param [created] - Action created time @@ -11998,6 +12032,8 @@ export { parseReportRouteParams, parseReportActionHtmlToText, requiresAttentionFromCurrentUser, + selectArchivedReportsIdSet, + selectFilteredReportActions, shouldAutoFocusOnKeyPress, shouldCreateNewMoneyRequestReport, shouldDisableDetailPage, diff --git a/src/libs/actions/ReportNavigation.ts b/src/libs/actions/ReportNavigation.ts new file mode 100644 index 0000000000000..682ff68f55dfe --- /dev/null +++ b/src/libs/actions/ReportNavigation.ts @@ -0,0 +1,13 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type LastSearchParams from '@src/types/onyx/ReportNavigation'; + +function saveLastSearchParams(value: LastSearchParams) { + Onyx.set(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY, value); +} + +function clearLastSearchParams() { + Onyx.set(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY, {}); +} + +export {clearLastSearchParams, saveLastSearchParams}; diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 65a07e6806d8b..8261832ae0edc 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -5,6 +5,7 @@ import type {FormOnyxValues} from '@components/Form/types'; import type {PaymentData, SearchQueryJSON} from '@components/Search/types'; import type {TransactionListItemType, TransactionReportGroupListItemType} from '@components/SelectionList/types'; import * as API from '@libs/API'; +import {waitForWrites} from '@libs/API'; import type {ExportSearchItemsToCSVParams, ExportSearchWithTemplateParams, ReportExportParams, SubmitReportParams} from '@libs/API/parameters'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import {getCommandURL} from '@libs/ApiUtils'; @@ -28,6 +29,15 @@ import type {PaymentInformation} from '@src/types/onyx/LastPaymentMethod'; import type {ConnectionName} from '@src/types/onyx/Policy'; import type {SearchPolicy, SearchReport, SearchTransaction} from '@src/types/onyx/SearchResults'; import type Nullable from '@src/types/utils/Nullable'; +import {saveLastSearchParams} from './ReportNavigation'; + +type OnyxSearchResponse = { + data: []; + search: { + offset: number; + hasMoreResults: boolean; + }; +}; function handleActionButtonPress( hash: number, @@ -299,11 +309,13 @@ function search({ searchKey, offset, shouldCalculateTotals = false, + prevReportsLength, }: { queryJSON: SearchQueryJSON; searchKey: SearchKey | undefined; offset?: number; shouldCalculateTotals?: boolean; + prevReportsLength?: number; }) { const {optimisticData, finallyData, failureData} = getOnyxLoadingData(queryJSON.hash, queryJSON, offset); const {flatFilters, ...queryJSONWithoutFlatFilters} = queryJSON; @@ -311,11 +323,47 @@ function search({ ...queryJSONWithoutFlatFilters, searchKey, offset, + filters: queryJSONWithoutFlatFilters.filters ?? null, shouldCalculateTotals, }; const jsonQuery = JSON.stringify(query); + saveLastSearchParams({ + queryJSON, + offset, + allowPostSearchRecount: false, + }); - API.read(READ_COMMANDS.SEARCH, {hash: queryJSON.hash, jsonQuery}, {optimisticData, finallyData, failureData}); + waitForWrites(READ_COMMANDS.SEARCH).then(() => { + // eslint-disable-next-line rulesdir/no-api-side-effects-method + API.makeRequestWithSideEffects(READ_COMMANDS.SEARCH, {hash: queryJSON.hash, jsonQuery}, {optimisticData, finallyData, failureData}).then((result) => { + const response = result?.onyxData?.[0]?.value as OnyxSearchResponse; + const reports = Object.keys(response?.data ?? {}) + .filter((key) => key.startsWith(ONYXKEYS.COLLECTION.REPORT)) + .map((key) => key.replace(ONYXKEYS.COLLECTION.REPORT, '')); + if (response?.search?.offset) { + // Indicates that search results are extended from the Report view (with navigation between reports), + // using previous results to enable correct counter behavior. + if (prevReportsLength) { + saveLastSearchParams({ + queryJSON, + offset, + hasMoreResults: !!response?.search?.hasMoreResults, + previousLengthOfResults: prevReportsLength, + allowPostSearchRecount: false, + }); + } + } else { + // Applies to all searches from the Search View + saveLastSearchParams({ + queryJSON, + offset, + hasMoreResults: !!response?.search?.hasMoreResults, + previousLengthOfResults: reports.length, + allowPostSearchRecount: true, + }); + } + }); + }); } /** diff --git a/src/pages/NewReportWorkspaceSelectionPage.tsx b/src/pages/NewReportWorkspaceSelectionPage.tsx index 2a116f0625795..f31fc07b4304d 100644 --- a/src/pages/NewReportWorkspaceSelectionPage.tsx +++ b/src/pages/NewReportWorkspaceSelectionPage.tsx @@ -20,6 +20,7 @@ import {getHeaderMessageForNonUserList} from '@libs/OptionsListUtils'; import {isPolicyAdmin, shouldShowPolicy} from '@libs/PolicyUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; +import isRHPOnSearchMoneyRequestReportPage from '@navigation/helpers/isRHPOnSearchMoneyRequestReportPage'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -37,29 +38,25 @@ function NewReportWorkspaceSelectionPage() { const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const {translate, localeCompare} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); + const isRHPOnReportInSearch = isRHPOnSearchMoneyRequestReportPage(); const [policies, fetchStatus] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); const shouldShowLoadingIndicator = isLoadingApp && !isOffline; - const navigateToNewReport = useCallback( (optimisticReportID: string) => { - if (shouldUseNarrowLayout) { + if (isRHPOnReportInSearch) { Navigation.setNavigationActionToMicrotaskQueue(() => { - Navigation.navigate(ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID: optimisticReportID}), {forceReplace: true}); + Navigation.dismissModal(); }); - return; } - // On wide screens we use dismissModal instead of forceReplace to avoid performance issues - Navigation.setNavigationActionToMicrotaskQueue(() => { - Navigation.dismissModal(); - }); + Navigation.setNavigationActionToMicrotaskQueue(() => { - Navigation.navigate(ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID: optimisticReportID})); + Navigation.navigate(ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID: optimisticReportID}), {forceReplace: isRHPOnReportInSearch || shouldUseNarrowLayout}); }); }, - [shouldUseNarrowLayout], + [isRHPOnReportInSearch, shouldUseNarrowLayout], ); const selectPolicy = useCallback( diff --git a/src/pages/Search/SearchMoneyRequestReportPage.tsx b/src/pages/Search/SearchMoneyRequestReportPage.tsx index a916e8ad382fc..17ffd1d7e9aa2 100644 --- a/src/pages/Search/SearchMoneyRequestReportPage.tsx +++ b/src/pages/Search/SearchMoneyRequestReportPage.tsx @@ -50,6 +50,8 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { const reportIDFromRoute = getNonEmptyStringOnyxID(route.params?.reportID); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`, {allowStaleData: true, canBeMissing: true}); + const shouldWaitForReportSync = report?.reportID !== reportIDFromRoute; + const [reportMetadata = defaultReportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportIDFromRoute}`, {canBeMissing: true, allowStaleData: true}); const [policies = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {allowStaleData: true, canBeMissing: false}); const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; @@ -132,6 +134,8 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { reportMetadata={reportMetadata} policy={policy} shouldDisplayReportFooter={isCurrentReportLoadedFromOnyx} + shouldWaitForReportSync={shouldWaitForReportSync} + key={report?.reportID} backToRoute={route.params.backTo} /> @@ -167,6 +171,8 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { reportMetadata={reportMetadata} policy={policy} shouldDisplayReportFooter={isCurrentReportLoadedFromOnyx} + shouldWaitForReportSync={shouldWaitForReportSync} + key={report?.reportID} backToRoute={route.params.backTo} /> diff --git a/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx index df0571ca13593..8436284fbc4b1 100644 --- a/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx +++ b/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx @@ -46,8 +46,10 @@ import { import {getQuickActionIcon, getQuickActionTitle, isQuickActionAllowed} from '@libs/QuickActionUtils'; import {generateReportID, getDisplayNameForParticipant, getIcons, getReportName, getWorkspaceChats, isPolicyExpenseChat} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; +import isOnSearchMoneyRequestReportPage from '@navigation/helpers/isOnSearchMoneyRequestReportPage'; import variables from '@styles/variables'; import {closeReactNativeApp} from '@userActions/HybridApp'; +import {clearLastSearchParams} from '@userActions/ReportNavigation'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -149,6 +151,8 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, ref selector: (policies) => Object.values(policies ?? {}).some((policy) => isPaidGroupPolicy(policy) && isPolicyMember(policy, currentUserPersonalDetails.login)), }); + const isReportInSearch = isOnSearchMoneyRequestReportPage(); + const groupPoliciesWithChatEnabled = getGroupPaidPoliciesWithExpenseChatEnabled(); /** @@ -480,6 +484,10 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, ref workspaceIDForReportCreation = groupPoliciesWithChatEnabled.at(0)?.id; } + if (isReportInSearch) { + clearLastSearchParams(); + } + if (!workspaceIDForReportCreation || (shouldRestrictUserBillableActions(workspaceIDForReportCreation) && groupPoliciesWithChatEnabled.length > 1)) { // If we couldn't guess the workspace to create the report, or a guessed workspace is past it's grace period and we have other workspaces to choose from Navigation.navigate(ROUTES.NEW_REPORT_WORKSPACE_SELECTION); @@ -493,6 +501,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, ref isSearchTopmostFullScreenRoute() ? ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID: createdReportID, backTo: Navigation.getActiveRoute()}) : ROUTES.REPORT_WITH_ID.getRoute(createdReportID, undefined, undefined, Navigation.getActiveRoute()), + {forceReplace: isReportInSearch}, ); }); } else { diff --git a/src/styles/utils/flex.ts b/src/styles/utils/flex.ts index aafda26f3acdd..82e41e7d37e14 100644 --- a/src/styles/utils/flex.ts +++ b/src/styles/utils/flex.ts @@ -113,6 +113,10 @@ export default { flexWrap: 'wrap', }, + flexNoWrap: { + flexWrap: 'nowrap', + }, + flexGrow0: { flexGrow: 0, }, diff --git a/src/styles/utils/sizing.ts b/src/styles/utils/sizing.ts index cf1630fc1cab2..e1863a04a7dc9 100644 --- a/src/styles/utils/sizing.ts +++ b/src/styles/utils/sizing.ts @@ -13,6 +13,10 @@ export default { height: '100%', }, + h7: { + height: 28, + }, + h10: { height: 40, }, diff --git a/src/types/onyx/ReportNavigation.ts b/src/types/onyx/ReportNavigation.ts new file mode 100644 index 0000000000000..a7af1edf38ba2 --- /dev/null +++ b/src/types/onyx/ReportNavigation.ts @@ -0,0 +1,38 @@ +import type {SearchQueryJSON} from '@components/Search/types'; +import type {SearchKey} from '@libs/SearchUIUtils'; + +/** + * Represents the parameters from the previous search invocation. + * This is used to persist search arguments between navigations within reports, + * and allows loading more search results as the user continues navigating. + */ +type LastSearchParams = { + /** + * The number of results returned in the previous search. + */ + previousLengthOfResults?: number; + /** + * Enables post-search recount based on extra criteria unknown during the initial search. + */ + allowPostSearchRecount?: boolean; + /** + * Indicates whether there are more results available beyond the last search. + */ + hasMoreResults?: boolean; + + /** + * The full query JSON object that was used in the last search. + */ + queryJSON?: SearchQueryJSON; + /** + * The current offset used in pagination for fetching the previous set of results. + */ + offset?: number; + + /** + *Optional witch field contain search key for last search performed + */ + searchKey?: SearchKey; +}; + +export default LastSearchParams; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index b1574fd787fb7..acc4d85e82d95 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -90,6 +90,7 @@ import type ReportActionsDraft from './ReportActionsDraft'; import type ReportActionsDrafts from './ReportActionsDrafts'; import type ReportMetadata from './ReportMetadata'; import type ReportNameValuePairs from './ReportNameValuePairs'; +import type LastSearchParams from './ReportNavigation'; import type ReportNextStep from './ReportNextStep'; import type ReportUserIsTyping from './ReportUserIsTyping'; import type {ReportFieldsViolations, ReportViolationName} from './ReportViolation'; @@ -271,6 +272,7 @@ export type { SidePanel, LastPaymentMethodType, ReportAttributesDerivedValue, + LastSearchParams, ReportTransactionsAndViolationsDerivedValue, OutstandingReportsByPolicyIDDerivedValue, ScheduleCallDraft,