diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 539e63cfda91..f15d83c13bbd 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -665,6 +665,9 @@ const ONYXKEYS = { /** Stores the current search page context (e.g., whether to show the search query) */ SEARCH_CONTEXT: 'searchContext', + /** Caches converted footer-total amounts (by transaction and by whole-search query, nested by currency) for the Search footer currency picker */ + SEARCH_FOOTER_CONVERSION: 'searchFooterConversion', + /** Maps each loaded search snapshot's hash to its original query string, used to fan optimistic IOU updates to every matching snapshot */ SEARCH_QUERY_BY_HASH: 'searchQueryByHash', @@ -1496,6 +1499,7 @@ type OnyxValuesMapping = { [ONYXKEYS.RECENT_SEARCHES]: Record; [ONYXKEYS.SAVED_SEARCHES]: OnyxTypes.SaveSearch; [ONYXKEYS.SEARCH_CONTEXT]: OnyxTypes.SearchContext; + [ONYXKEYS.SEARCH_FOOTER_CONVERSION]: OnyxTypes.SearchFooterConversion; [ONYXKEYS.SEARCH_QUERY_BY_HASH]: Record; [ONYXKEYS.RECENTLY_USED_CURRENCIES]: string[]; [ONYXKEYS.ACTIVE_CLIENTS]: string[]; diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index df8582d6c532..101320b5ca29 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -24,7 +24,7 @@ import type IconAsset from '@src/types/utils/IconAsset'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; import type {ForwardedRef} from 'react'; -import type {AccessibilityState, GestureResponderEvent, LayoutChangeEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {AccessibilityState, GestureResponderEvent, LayoutChangeEvent, NativeSyntheticEvent, StyleProp, TargetedEvent, TextStyle, ViewStyle} from 'react-native'; import {useIsFocused} from '@react-navigation/native'; import React, {useState} from 'react'; @@ -104,6 +104,12 @@ type ButtonProps = Partial & /** Callback that is called when mousedown is triggered. */ onMouseDown?: (e: React.MouseEvent) => void; + /** A function that is called when the button receives focus */ + onFocus?: (event: NativeSyntheticEvent) => void; + + /** A function that is called when the button loses focus */ + onBlur?: (event: NativeSyntheticEvent) => void; + /** Call the onPress function when Enter key is pressed */ pressOnEnter?: boolean; @@ -279,6 +285,8 @@ function Button({ onPressIn = () => {}, onPressOut = () => {}, onMouseDown = undefined, + onFocus = undefined, + onBlur = undefined, pressOnEnter = false, enterKeyEventListenerPriority = 0, @@ -516,6 +524,8 @@ function Button({ onPressIn={onPressIn} onPressOut={onPressOut} onMouseDown={onMouseDown} + onFocus={onFocus} + onBlur={onBlur} shouldBlendOpacity={shouldBlendOpacity} disabled={isLoading || isDisabled} wrapperStyle={[ diff --git a/src/components/ButtonComposed/Button.tsx b/src/components/ButtonComposed/Button.tsx index 63202ac2b807..e5d8ee97d8e0 100644 --- a/src/components/ButtonComposed/Button.tsx +++ b/src/components/ButtonComposed/Button.tsx @@ -34,6 +34,8 @@ function Button({ onPressIn = () => {}, onPressOut = () => {}, onMouseDown = undefined, + onFocus = undefined, + onBlur = undefined, style = [], disabledStyle, innerStyles = [], @@ -170,6 +172,8 @@ function Button({ onPressIn={onPressIn} onPressOut={onPressOut} onMouseDown={onMouseDown} + onFocus={onFocus} + onBlur={onBlur} onHoverIn={!isDisabled || !stayNormalOnDisable ? () => setIsHovered(true) : undefined} onHoverOut={!isDisabled || !stayNormalOnDisable ? () => setIsHovered(false) : undefined} onPress={(event) => { diff --git a/src/components/ButtonComposed/types.ts b/src/components/ButtonComposed/types.ts index d975e079ddd8..71a089bdee06 100644 --- a/src/components/ButtonComposed/types.ts +++ b/src/components/ButtonComposed/types.ts @@ -5,7 +5,7 @@ import type WithSentryLabel from '@src/types/utils/SentryLabel'; import type {ForwardedRef} from 'react'; import type React from 'react'; -import type {AccessibilityState, GestureResponderEvent, LayoutChangeEvent, StyleProp, View, ViewStyle} from 'react-native'; +import type {AccessibilityState, GestureResponderEvent, LayoutChangeEvent, NativeSyntheticEvent, StyleProp, TargetedEvent, View, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; type ButtonEventsProps = { @@ -26,6 +26,12 @@ type ButtonEventsProps = { /** Invoked on mount and layout changes */ onLayout?: (event: LayoutChangeEvent) => void; + + /** A function that is called when the button receives focus */ + onFocus?: (event: NativeSyntheticEvent) => void; + + /** A function that is called when the button loses focus */ + onBlur?: (event: NativeSyntheticEvent) => void; }; type ButtonBehaviorProps = { diff --git a/src/components/Search/FilterComponents/ListFilterViewWrapper.tsx b/src/components/Search/FilterComponents/ListFilterViewWrapper.tsx index 00200809a8f3..24cf0fa59985 100644 --- a/src/components/Search/FilterComponents/ListFilterViewWrapper.tsx +++ b/src/components/Search/FilterComponents/ListFilterViewWrapper.tsx @@ -15,19 +15,21 @@ type ListFilterWrapperProps = { hasHeader?: boolean; isSearchable?: boolean; extraHeight?: number; + shouldUseFixedPopoverHeight?: boolean; }; -function ListFilterView({children, itemCount, itemHeight, hasTitle = true, hasHeader, isSearchable, extraHeight}: ListFilterWrapperProps) { +function ListFilterView({children, itemCount, itemHeight, hasTitle = true, hasHeader, isSearchable, extraHeight, shouldUseFixedPopoverHeight}: ListFilterWrapperProps) { const styles = useThemeStyles(); const {windowHeight} = useWindowDimensions(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, isInLandscapeMode} = useResponsiveLayout(); + const heightItemCount = shouldUseFixedPopoverHeight ? Number.MAX_SAFE_INTEGER : itemCount || 1; return ( = SearchFilterCommonProps | undefi /** Custom height for each item in the list */ itemHeight?: number; + /** Whether the popover keeps a fixed height instead of growing with its content */ + shouldUseFixedPopoverHeight?: boolean; allowDeselect?: boolean; hasTitle?: boolean; hasHeader?: boolean; @@ -58,6 +60,7 @@ function SingleSelectImpl({ hasTitle, hasHeader, itemHeight, + shouldUseFixedPopoverHeight, footer, allowDeselect, onChange, @@ -132,6 +135,7 @@ function SingleSelectImpl({ hasTitle={hasTitle} isSearchable={isSearchable} itemHeight={itemHeight ?? variables.optionRowHeightCompact} + shouldUseFixedPopoverHeight={shouldUseFixedPopoverHeight} > void; + + /** Function to call to close the overlay */ + closeOverlay: () => void; + + /** Function to call when a currency is selected */ + onChange: (item: SingleSelectItem | undefined) => void; + + /** The currently selected currency code */ + value?: string; + + /** The currency code to select when reset is clicked */ + defaultValue?: string; + + /** Search input placeholder */ + searchPlaceholder?: string; + + /** Whether the currency list should be visible */ + shouldShowList?: boolean; +}; + +/** + * Searchable single-select currency picker popup, used by the Search footer's total-spend currency selector. + */ +function CurrencyPopup({label, onBackButtonPress, onChange, closeOverlay, value, defaultValue, searchPlaceholder, shouldShowList}: CurrencyPopupProps) { + const {currencyList} = useCurrencyListState(); + const {getCurrencySymbol} = useCurrencyListActions(); + + const currencyOptions = getCurrencyOptions(currencyList, getCurrencySymbol); + const currencyValue = currencyOptions.find((option) => option.value === value); + + return ( + + ); +} + +export default CurrencyPopup; diff --git a/src/components/Search/FilterDropdowns/DisplayPopup.tsx b/src/components/Search/FilterDropdowns/DisplayPopup.tsx index d1d0844312ac..68b87f8001b1 100644 --- a/src/components/Search/FilterDropdowns/DisplayPopup.tsx +++ b/src/components/Search/FilterDropdowns/DisplayPopup.tsx @@ -26,8 +26,8 @@ import type {OnyxEntry} from 'react-native-onyx'; import React, {useState} from 'react'; +import CurrencyPopup from './CurrencyPopup'; import GroupByPopup from './GroupByPopup'; -import GroupCurrencyPopup from './GroupCurrencyPopup'; import SingleSelectPopup from './SingleSelectPopup'; import SortByPopup from './SortByPopup'; import SortOrderPopup from './SortOrderPopup'; @@ -193,10 +193,13 @@ function DisplayPopup({queryJSON, searchResults, closeOverlay, onSort}: DisplayP ); case CONST.SEARCH.SYNTAX_FILTER_KEYS.GROUP_CURRENCY: return ( - updateFilterForm({groupCurrency: item?.value})} + label={translate('common.groupCurrency')} onBackButtonPress={goBack} closeOverlay={closeOverlay} + searchPlaceholder={translate('common.groupCurrency')} /> ); case CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW: diff --git a/src/components/Search/FilterDropdowns/GroupCurrencyPopup.tsx b/src/components/Search/FilterDropdowns/GroupCurrencyPopup.tsx deleted file mode 100644 index f05d698025a6..000000000000 --- a/src/components/Search/FilterDropdowns/GroupCurrencyPopup.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import {useCurrencyListActions, useCurrencyListState} from '@components/CurrencyListContextProvider'; -import type {SingleSelectItem} from '@components/Search/FilterComponents/SingleSelect'; - -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; - -import {getCurrencyOptions} from '@libs/SearchUIUtils'; - -import ONYXKEYS from '@src/ONYXKEYS'; -import type {SearchAdvancedFiltersForm} from '@src/types/form'; - -import type {OnyxEntry} from 'react-native-onyx'; - -import React from 'react'; - -import SingleSelectPopup from './SingleSelectPopup'; - -type GroupCurrencyPopupProps = { - onBackButtonPress: () => void; - closeOverlay: () => void; - onChange: (item: SingleSelectItem | undefined) => void; -}; - -function filterGroupCurrencySelector(searchAdvancedFiltersForm: OnyxEntry) { - return searchAdvancedFiltersForm?.groupCurrency; -} - -function GroupCurrencyPopup({onBackButtonPress, onChange, closeOverlay}: GroupCurrencyPopupProps) { - const {translate} = useLocalize(); - const {currencyList} = useCurrencyListState(); - const {getCurrencySymbol} = useCurrencyListActions(); - const groupCurrencyOptions = getCurrencyOptions(currencyList, getCurrencySymbol); - const [groupCurrency] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: filterGroupCurrencySelector}); - - const groupCurrencyValue = groupCurrencyOptions.find((option) => option.value === groupCurrency); - - return ( - - ); -} - -export default GroupCurrencyPopup; diff --git a/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx b/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx index 0832c7f6ebff..da4a5592ee52 100644 --- a/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx +++ b/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx @@ -51,6 +51,9 @@ type SingleSelectPopupProps = { /** Whether SelectionList of popup should stay mounted when popup is not visible. */ shouldShowList?: boolean; + + /** Whether the popover should keep a fixed height while filtering results. */ + shouldUseFixedPopoverHeight?: boolean; }; function SingleSelectPopup({ @@ -68,6 +71,7 @@ function SingleSelectPopup({ itemHeight, showLabel, shouldShowList = true, + shouldUseFixedPopoverHeight, }: SingleSelectPopupProps) { const [selectedItem, setSelectedItem] = useState(value); @@ -104,6 +108,7 @@ function SingleSelectPopup({ selectionListStyle={selectionListStyle} shouldShowList={shouldShowList} itemHeight={itemHeight} + shouldUseFixedPopoverHeight={shouldUseFixedPopoverHeight} /> diff --git a/src/components/Search/SearchPageFooter.tsx b/src/components/Search/SearchPageFooter.tsx index 7808b72fcb38..cd5fe40c4021 100644 --- a/src/components/Search/SearchPageFooter.tsx +++ b/src/components/Search/SearchPageFooter.tsx @@ -1,6 +1,9 @@ +import Button from '@components/ButtonComposed'; import Text from '@components/Text'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; +import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -8,47 +11,141 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import React, {useMemo} from 'react'; -import {View} from 'react-native'; +import CONST from '@src/CONST'; + +import React, {useMemo, useState} from 'react'; +import {StyleSheet, View} from 'react-native'; + +import type {SingleSelectItem} from './FilterComponents/SingleSelect'; +import type {ButtonComponentProps, FilterPopupButtonProps} from './FilterDropdowns/FilterPopupButton'; + +import CurrencyPopup from './FilterDropdowns/CurrencyPopup'; +import FilterPopupButton from './FilterDropdowns/FilterPopupButton'; +import SearchPageFooterSkeleton from './SearchPageFooterSkeleton'; type SearchPageFooterProps = { + /** Number of expenses represented by the footer total */ count: number | undefined; + + /** Total amount to display in the footer */ total: number | undefined; + + /** Currency code for the displayed total */ currency: string | undefined; + + /** Currency code used when the footer currency is reset */ + defaultCurrency: string | undefined; + + /** Whether the footer total is currently refreshing */ + isTotalLoading: boolean; + + /** Function to call when the footer currency changes */ + onCurrencyChange: (currency: string | undefined) => void; }; -function SearchPageFooter({count, total, currency}: SearchPageFooterProps) { +function SearchPageFooter({count, total, currency, defaultCurrency, isTotalLoading, onCurrencyChange}: SearchPageFooterProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); const {isOffline} = useNetwork(); + const icons = useMemoizedLazyExpensifyIcons(['DownArrow']); const {shouldUseNarrowLayout} = useResponsiveLayout(); + const [isTotalButtonFocused, setIsTotalButtonFocused] = useState(false); + const valueTextStyle = useMemo(() => (isOffline ? [styles.textLabelSupporting, styles.labelStrong] : [styles.labelStrong]), [isOffline, styles]); - return ( - {}, {isActive: isTotalButtonFocused, shouldBubble: false, shouldPreventDefault: false}); + + const handleCurrencyChange = (item: SingleSelectItem | undefined) => { + if (isOffline) { + return; + } + + const nextCurrency = item?.value ?? defaultCurrency; + if (!nextCurrency) { + return; + } + onCurrencyChange(nextCurrency === defaultCurrency ? undefined : nextCurrency); + }; + + const renderCurrencyPopup: FilterPopupButtonProps['PopoverComponent'] = ({closeOverlay, isExpanded}) => ( + + ); + + const totalButton = (props: ButtonComponentProps) => ( + + ); + + return ( + + + + {`${translate('common.expenses')}:`} + {count} + + {typeof total === 'number' && ( + + {`${translate('common.totalSpend')}:`} + + + )} - {typeof total === 'number' && ( - - {`${translate('common.totalSpend')}:`} - {convertToDisplayString(total, currency)} + {isTotalLoading && ( + + )} diff --git a/src/components/Search/SearchPageFooterSkeleton.tsx b/src/components/Search/SearchPageFooterSkeleton.tsx new file mode 100644 index 000000000000..4e284f2d837a --- /dev/null +++ b/src/components/Search/SearchPageFooterSkeleton.tsx @@ -0,0 +1,55 @@ +import SkeletonRect from '@components/SkeletonRect'; +import SkeletonViewContentLoader from '@components/SkeletonViewContentLoader'; + +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; +import useSkeletonSpan from '@libs/telemetry/useSkeletonSpan'; + +import React from 'react'; +import {View} from 'react-native'; + +const SKELETON_HEIGHT = 20; +const BAR_HEIGHT = 8; +const BAR_VERTICAL_OFFSET = (SKELETON_HEIGHT - BAR_HEIGHT) / 2; +const COUNT_BAR_WIDTH = 80; +const TOTAL_BAR_WIDTH = 110; +const BAR_GAP = 16; +const TOTAL_BAR_OFFSET = COUNT_BAR_WIDTH + BAR_GAP; +const SKELETON_WIDTH = TOTAL_BAR_OFFSET + TOTAL_BAR_WIDTH; + +type SearchPageFooterSkeletonProps = { + /** Context describing why the skeleton is rendered, for telemetry */ + reasonAttributes: SkeletonSpanReasonAttributes; +}; + +function SearchPageFooterSkeleton({reasonAttributes}: SearchPageFooterSkeletonProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + useSkeletonSpan('SearchPageFooterSkeleton', reasonAttributes); + + return ( + + + + + + + ); +} + +export default SearchPageFooterSkeleton; diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index 7c40a8c30c0a..ff47a3b90354 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -1,13 +1,19 @@ +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; +import {getFooterConvertedAmounts} from '@libs/actions/Search'; import {isGroupEntry} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchResults} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import React from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; + +import type {SelectedTransactionInfo, SelectedTransactions} from './types'; import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionContext} from './SearchContext'; import SearchPageFooter from './SearchPageFooter'; @@ -17,45 +23,392 @@ type SearchSelectionFooterProps = { searchResults: OnyxEntry; }; +type FooterCurrencyState = { + /** Search hash this footer currency state belongs to */ + searchHash: number | undefined; + + /** Custom currency selected in the footer, if any */ + selectedCurrency: string | undefined; + + /** Default currency captured for this search */ + defaultCurrency: string | undefined; +}; + +function getGroupCount(group: unknown): number { + if (group && typeof group === 'object' && 'count' in group && typeof group.count === 'number') { + return group.count; + } + + return 0; +} + +// The live default-currency figure a row contributes to the footer total (also what the footer falls back to before a +// conversion arrives). The footer stamps each conversion against this value and compares it on every render, so an +// inline edit that moves it is detected and the cached conversion is fetched again. +function getEntrySource(entry: SelectedTransactionInfo): number { + return entry.groupAmount ?? -Math.abs(entry.amount); +} + +// Every selected row needs a fresh cached conversion for the target currency before the selected total can be shown +// in that currency. A whole-group selection converts by group; an individual row converts by its transaction (so a +// grouped selection can mix the two). Report-view rows carry no transaction of their own and are ignored. +function areAllSelectedEntriesConverted(selectedTransactions: SelectedTransactions, isGroupFresh: (key: string) => boolean, isTransactionFresh: (transactionID: string) => boolean): boolean { + return Object.keys(selectedTransactions).every((key) => { + if (isGroupEntry(key)) { + return isGroupFresh(key); + } + + const transaction = selectedTransactions[key]; + if (transaction.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === transaction.reportID) { + return true; + } + + const transactionID = transaction.transaction?.transactionID; + if (!transactionID) { + return false; + } + + return isTransactionFresh(transactionID); + }); +} + +// The Reports search converts a selection by report, so every selected report needs a fresh cached converted total. +function areAllSelectedReportsConverted(selectedReportIDs: string[], isReportFresh: (reportID: string) => boolean): boolean { + return selectedReportIDs.every(isReportFresh); +} + // Self-subscribing footer leaf. Owns the `selectedTransactions` read so a checkbox press re-renders only this // footer — not SearchPage and the list it contains. function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { - const {selectedTransactions, areAllMatchingItemsSelected} = useSearchSelectionContext(); + const {selectedTransactions, areAllMatchingItemsSelected, selectedReports} = useSearchSelectionContext(); const {currentSearchResults} = useSearchResultsContext(); - const {currentSearchKey, currentSearchQueryJSON} = useSearchQueryContext(); + const {currentSearchHash, currentSearchKey, currentSearchQueryJSON} = useSearchQueryContext(); const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true, areAllMatchingItemsSelected); + const {isOffline} = useNetwork(); + const [footerCurrencyState, setFooterCurrencyState] = useState({ + searchHash: undefined, + selectedCurrency: undefined, + defaultCurrency: undefined, + }); + const isCurrentFooterState = footerCurrencyState.searchHash === currentSearchHash; + const selectedCurrency = isCurrentFooterState ? footerCurrencyState.selectedCurrency : undefined; + const defaultFooterCurrency = isCurrentFooterState ? footerCurrencyState.defaultCurrency : undefined; + + // The Auth command merges converted figures here (by transaction, report, group, and query hash, each nested + // under the target currency); the live search snapshot stays in its original currency. + const [footerConversion] = useOnyx(ONYXKEYS.SEARCH_FOOTER_CONVERSION); + const convertedTransactions = footerConversion?.transactions; + const convertedReports = footerConversion?.reports; + const convertedGroups = footerConversion?.groups; + const convertedSearchTotal = footerConversion?.searchTotals?.[currentSearchHash]; + const failedConversionCurrencies = footerConversion?.failedCurrencies; + + // Source figures each conversion was stamped against. A conversion is "fresh" only while its stamped source + // still equals the live snapshot value; an inline edit moves the live value and makes the conversion stale. + const conversionSources = footerConversion?.sources; + + // The Reports search converts a selection by report. Other searches convert per row — by group for a whole-group + // selection (grouped views) and by transaction otherwise — so a grouped selection can mix whole groups and + // individual transactions from other groups. + const isReportsSearch = currentSearchQueryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; const metadata = searchResults?.search; - const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); + const metadataCount = metadata?.count; + const metadataCurrency = metadata?.currency; + const metadataTotal = metadata?.total; + const selectedTransactionsKeys = useMemo(() => Object.keys(selectedTransactions ?? {}), [selectedTransactions]); + const selectedExpenseCount = useMemo( + () => + selectedTransactionsKeys.reduce((count, key) => { + if (isGroupEntry(key)) { + const group: unknown = currentSearchResults?.data?.[key]; + return count + getGroupCount(group); + } + const item = selectedTransactions[key]; + if (item.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === item.reportID) { + return count; + } + return count + 1; + }, 0), + [currentSearchResults?.data, selectedTransactions, selectedTransactionsKeys], + ); + + // Individually-selected transactions (loose rows in a grouped view, or every row on a flat search). + const selectedTransactionIDs = useMemo( + () => selectedTransactionsKeys.map((key) => selectedTransactions[key]?.transaction?.transactionID).filter((transactionID): transactionID is string => !!transactionID), + [selectedTransactions, selectedTransactionsKeys], + ); + const selectedReportIDs = useMemo( + () => Array.from(new Set(selectedTransactionsKeys.map((key) => selectedTransactions[key]?.reportID).filter((reportID): reportID is string => !!reportID))), + [selectedTransactions, selectedTransactionsKeys], + ); + const selectedGroupKeys = useMemo(() => selectedTransactionsKeys.filter(isGroupEntry), [selectedTransactionsKeys]); + + // Live default-currency source figures, keyed the same way as the conversion cache, captured from the current + // selection/snapshot on every render. The freshness checks below compare these to the figures stamped when each + // conversion was requested. + const transactionSourceByID = useMemo(() => { + const sources: Record = {}; + for (const key of selectedTransactionsKeys) { + const transactionID = selectedTransactions[key]?.transaction?.transactionID; + if (!isGroupEntry(key) && transactionID) { + sources[transactionID] = getEntrySource(selectedTransactions[key]); + } + } + return sources; + }, [selectedTransactions, selectedTransactionsKeys]); + const groupSourceByKey = useMemo(() => { + const sources: Record = {}; + for (const key of selectedGroupKeys) { + sources[key] = getEntrySource(selectedTransactions[key]); + } + return sources; + }, [selectedGroupKeys, selectedTransactions]); + const reportSourceByID = useMemo(() => { + const sources: Record = {}; + for (const report of selectedReports) { + if (report.reportID) { + sources[report.reportID] = report.total; + } + } + return sources; + }, [selectedReports]); + + // A conversion is fresh only when its converted figure is cached AND the source it was stamped against still equals + // the live source — so an inline edit that moves the live source makes it stale and triggers a refetch below. + const isTransactionFresh = useCallback( + (transactionID: string, currency: string) => + convertedTransactions?.[transactionID]?.[currency] !== undefined && conversionSources?.transactions?.[transactionID]?.[currency] === transactionSourceByID[transactionID], + [conversionSources, convertedTransactions, transactionSourceByID], + ); + const isGroupFresh = useCallback( + (key: string, currency: string) => convertedGroups?.[key]?.[currency] !== undefined && conversionSources?.groups?.[key]?.[currency] === groupSourceByKey[key], + [conversionSources, convertedGroups, groupSourceByKey], + ); + const isReportFresh = useCallback( + (reportID: string, currency: string) => convertedReports?.[reportID]?.[currency] !== undefined && conversionSources?.reports?.[reportID]?.[currency] === reportSourceByID[reportID], + [conversionSources, convertedReports, reportSourceByID], + ); + + const areAllSelectedForFooter = areAllMatchingItemsSelected || (selectedTransactionsKeys.length > 0 && metadataCount !== undefined && selectedExpenseCount === metadataCount); + const hasPartialSelection = selectedTransactionsKeys.length > 0 && !areAllSelectedForFooter; + + // Use the per-selection (client) total for a partial selection; nothing-selected and everything-selected both fall + // to the whole-search grand total, which every search type now returns converted, keyed by the search hash. + const shouldUseClientTotal = !metadataCount || hasPartialSelection; + const firstSelectedTransactionKey = selectedTransactionsKeys.at(0); + const firstSelectedTransaction = firstSelectedTransactionKey ? selectedTransactions[firstSelectedTransactionKey] : undefined; + const selectedTransactionDefaultCurrency = firstSelectedTransaction?.groupCurrency ?? firstSelectedTransaction?.currency; + const effectiveDefaultCurrency = defaultFooterCurrency ?? metadataCurrency ?? selectedTransactionDefaultCurrency; + const hasCustomFooterCurrency = !!selectedCurrency && selectedCurrency !== effectiveDefaultCurrency; + + // The most recent conversion request for this currency failed, so stop waiting on a converted value that isn't coming. + const hasConversionFailed = hasCustomFooterCurrency && !!selectedCurrency && !!failedConversionCurrencies?.[selectedCurrency]; + + const selectedCurrencyConvertedTotal = hasCustomFooterCurrency && selectedCurrency ? convertedSearchTotal?.[selectedCurrency] : undefined; + + // The whole-search grand total is fresh only while its stamped source still equals the live snapshot total. + const isSearchTotalFresh = !!selectedCurrencyConvertedTotal && !!selectedCurrency && conversionSources?.searchTotals?.[currentSearchHash]?.[selectedCurrency] === metadataTotal; + + // Whether the selection has anything to convert per-row: reports on the Reports search, otherwise selected whole + // groups and/or individual transactions. + const hasConvertibleSelection = isReportsSearch ? selectedReportIDs.length > 0 : selectedGroupKeys.length > 0 || selectedTransactionIDs.length > 0; + + const areAllSelectedConverted = useMemo(() => { + if (!hasCustomFooterCurrency || !selectedCurrency) { + return false; + } + return isReportsSearch + ? areAllSelectedReportsConverted(selectedReportIDs, (reportID) => isReportFresh(reportID, selectedCurrency)) + : areAllSelectedEntriesConverted( + selectedTransactions, + (key) => isGroupFresh(key, selectedCurrency), + (transactionID) => isTransactionFresh(transactionID, selectedCurrency), + ); + }, [hasCustomFooterCurrency, isGroupFresh, isReportFresh, isReportsSearch, isTransactionFresh, selectedCurrency, selectedReportIDs, selectedTransactions]); + + // Show the loading skeleton only while a conversion can still arrive — there is something to convert, the request + // can be made (online) and hasn't failed. Otherwise the footer stays on the default-currency total instead of a + // skeleton that would never resolve. + const isFooterTotalConverting = + !isOffline && !hasConversionFailed && hasCustomFooterCurrency && (shouldUseClientTotal ? hasConvertibleSelection && !areAllSelectedConverted : !isSearchTotalFresh); + const shouldShowFooter = (!areAllMatchingItemsSelected && selectedTransactionsKeys.length > 0) || (shouldAllowFooterTotals && !!metadata?.count); + // Fetch converted figures whenever a custom currency is chosen and the cache does not yet hold a fresh conversion + // for what the footer needs. Each request stamps the source figures it converts, so the freshness checks keep this + // to one request per out-of-coverage change (or per edit) rather than one per checkbox. + useEffect(() => { + // No conversion can complete offline, so don't queue reads that can't resolve; the effect re-runs on reconnect. + if (isOffline || !hasCustomFooterCurrency || !currentSearchQueryJSON || !selectedCurrency) { + return; + } + + if (shouldUseClientTotal) { + if (areAllSelectedConverted) { + return; + } + if (isReportsSearch) { + if (selectedReportIDs.length > 0) { + getFooterConvertedAmounts({ + queryJSON: currentSearchQueryJSON, + searchKey: currentSearchKey, + targetCurrency: selectedCurrency, + reportIDList: selectedReportIDs.join(','), + sources: {reports: Object.fromEntries(selectedReportIDs.map((reportID) => [reportID, {[selectedCurrency]: reportSourceByID[reportID]}]))}, + }); + } + return; + } + + // Selected whole groups: one grouped request (derived from the query's groupBy) returns every group's + // converted total, so no ID list is sent. + if (selectedGroupKeys.some((key) => !isGroupFresh(key, selectedCurrency))) { + getFooterConvertedAmounts({ + queryJSON: currentSearchQueryJSON, + searchKey: currentSearchKey, + targetCurrency: selectedCurrency, + sources: {groups: Object.fromEntries(selectedGroupKeys.map((key) => [key, {[selectedCurrency]: groupSourceByKey[key]}]))}, + }); + } + + // Individually-selected transactions convert by transaction ID (the loose rows in a grouped view, or the + // whole selection on a flat search). + if (selectedTransactionIDs.some((transactionID) => !isTransactionFresh(transactionID, selectedCurrency))) { + getFooterConvertedAmounts({ + queryJSON: currentSearchQueryJSON, + searchKey: currentSearchKey, + targetCurrency: selectedCurrency, + transactionIDList: selectedTransactionIDs.join(','), + sources: {transactions: Object.fromEntries(selectedTransactionIDs.map((transactionID) => [transactionID, {[selectedCurrency]: transactionSourceByID[transactionID]}]))}, + }); + } + return; + } + + // Nothing/everything selected: fetch the whole-search converted grand total (returned keyed by the search + // hash — flat via the window total, reports via searchTotalsMetadata, grouped via the summed groups). + if (!isSearchTotalFresh) { + getFooterConvertedAmounts({ + queryJSON: currentSearchQueryJSON, + searchKey: currentSearchKey, + targetCurrency: selectedCurrency, + sources: metadataTotal !== undefined ? {searchTotals: {[currentSearchHash]: {[selectedCurrency]: metadataTotal}}} : undefined, + }); + } + }, [ + areAllSelectedConverted, + currentSearchHash, + currentSearchKey, + currentSearchQueryJSON, + groupSourceByKey, + hasCustomFooterCurrency, + isGroupFresh, + isOffline, + isReportsSearch, + isSearchTotalFresh, + isTransactionFresh, + metadataTotal, + reportSourceByID, + selectedCurrency, + selectedGroupKeys, + selectedReportIDs, + selectedTransactionIDs, + shouldUseClientTotal, + transactionSourceByID, + ]); + + const handleFooterCurrencyChange = useCallback( + (currency: string | undefined) => { + setFooterCurrencyState({ + searchHash: currentSearchHash, + selectedCurrency: currency, + defaultCurrency: effectiveDefaultCurrency, + }); + }, + [currentSearchHash, effectiveDefaultCurrency], + ); + + const footerData = useMemo(() => { + if (!shouldAllowFooterTotals && selectedTransactionsKeys.length === 0) { + return {count: undefined, total: undefined, currency: undefined}; + } + + const selectedTransactionItems = Object.values(selectedTransactions); + const fallbackCurrency = effectiveDefaultCurrency ?? selectedTransactionItems.at(0)?.groupCurrency ?? selectedTransactionItems.at(0)?.currency; + + if (shouldUseClientTotal) { + const shouldUseConvertedSelectedTotal = hasCustomFooterCurrency && areAllSelectedConverted && !hasConversionFailed && !!selectedCurrency; + + // Reports sum each selected report's converted total; other searches sum per row — whole groups from the + // groups cache, individual transactions from the transactions cache — falling back to the default per-row + // amount until the conversion is ready, which keeps the footer on the default currency meanwhile. + let total; + if (shouldUseConvertedSelectedTotal && isReportsSearch && selectedCurrency) { + total = selectedReportIDs.reduce((acc, reportID) => acc - (convertedReports?.[reportID]?.[selectedCurrency] ?? 0), 0); + } else { + total = selectedTransactionsKeys.reduce((acc, key) => { + const transaction = selectedTransactions[key]; + let convertedAmount; + if (shouldUseConvertedSelectedTotal && selectedCurrency) { + if (isGroupEntry(key)) { + convertedAmount = convertedGroups?.[key]?.[selectedCurrency]; + } else if (transaction.transaction?.transactionID) { + convertedAmount = convertedTransactions?.[transaction.transaction.transactionID]?.[selectedCurrency]; + } + } + return acc - (convertedAmount ?? transaction.groupAmount ?? -Math.abs(transaction.amount)); + }, 0); + } + + return {count: selectedExpenseCount, total, currency: shouldUseConvertedSelectedTotal ? selectedCurrency : fallbackCurrency}; + } + + if (hasCustomFooterCurrency && isSearchTotalFresh && !hasConversionFailed && selectedCurrencyConvertedTotal) { + return {count: selectedCurrencyConvertedTotal.count, total: selectedCurrencyConvertedTotal.total, currency: selectedCurrency}; + } + + return {count: metadataCount, total: metadataTotal, currency: effectiveDefaultCurrency ?? metadataCurrency}; + }, [ + areAllSelectedConverted, + convertedGroups, + convertedReports, + convertedTransactions, + effectiveDefaultCurrency, + hasConversionFailed, + hasCustomFooterCurrency, + isReportsSearch, + isSearchTotalFresh, + metadataCount, + metadataCurrency, + metadataTotal, + selectedCurrency, + selectedCurrencyConvertedTotal, + selectedExpenseCount, + selectedReportIDs, + selectedTransactions, + selectedTransactionsKeys, + shouldAllowFooterTotals, + shouldUseClientTotal, + ]); + if (!shouldShowFooter) { return null; } - const shouldUseClientTotal = !metadata?.count || (selectedTransactionsKeys.length > 0 && !areAllMatchingItemsSelected); - const selectedTransactionItems = Object.values(selectedTransactions); - const currency = metadata?.currency ?? selectedTransactionItems.at(0)?.groupCurrency ?? selectedTransactionItems.at(0)?.currency; - const count = shouldUseClientTotal - ? selectedTransactionsKeys.reduce((acc, key) => { - if (isGroupEntry(key)) { - const group = currentSearchResults?.data?.[key]; - return acc + (group?.count ?? 0); - } - const item = selectedTransactions[key]; - if (item.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === item.reportID) { - return acc; - } - return acc + 1; - }, 0) - : metadata?.count; - const total = shouldUseClientTotal ? selectedTransactionItems.reduce((acc, transaction) => acc - (transaction.groupAmount ?? -Math.abs(transaction.amount)), 0) : metadata?.total; + // A partial selection shows a client-side subtotal that is ready immediately, so only show the search-loading + // skeleton when the footer is displaying the whole-search total. (Load-more requests also set metadata.isLoading + // but don't recalculate totals, so gate on offset 0.) + const isFooterTotalLoading = isFooterTotalConverting || (!hasPartialSelection && !!metadata?.isLoading && metadata?.offset === 0); return ( ); } diff --git a/src/libs/API/parameters/GetTransactionsConvertedAmount.ts b/src/libs/API/parameters/GetTransactionsConvertedAmount.ts new file mode 100644 index 000000000000..3379d7202897 --- /dev/null +++ b/src/libs/API/parameters/GetTransactionsConvertedAmount.ts @@ -0,0 +1,17 @@ +import type {SearchQueryString} from '@components/Search/types'; + +type GetTransactionsConvertedAmountParams = { + /** The serialized search query whose transactions should be converted */ + jsonQuery: SearchQueryString; + + /** The currency the amounts should be converted to */ + targetCurrency: string; + + /** Comma-separated transaction IDs to scope the conversion to a specific selection; omitted for the whole-search total */ + transactionIDList?: string; + + /** Comma-separated report IDs to convert each selected report's total; used by the Reports search */ + reportIDList?: string; +}; + +export default GetTransactionsConvertedAmountParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index dda7b55b18e2..f89ede438a88 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -331,6 +331,7 @@ export type {default as LeavePolicyParams} from './LeavePolicyParams'; export type {default as OpenPolicyAccountingPageParams} from './OpenPolicyAccountingPageParams'; export type {default as DismissViolationParams} from './DismissViolationParams'; export type {default as SearchParams} from './Search'; +export type {default as GetTransactionsConvertedAmountParams} from './GetTransactionsConvertedAmount'; export type {default as SendInvoiceParams} from './SendInvoiceParams'; export type {default as PayInvoiceParams} from './PayInvoiceParams'; export type {default as MarkAsCashParams} from './MarkAsCashParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index e57c87cf6359..711580702435 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -1365,6 +1365,7 @@ const READ_COMMANDS = { OPEN_SEARCH_PAGE: 'OpenSearchPage', OPEN_SEARCH_CARD_FILTERS_PAGE: 'OpenSearchCardFiltersPage', SEARCH: 'Search', + GET_TRANSACTIONS_CONVERTED_AMOUNT: 'GetTransactionsConvertedAmount', GET_OLDER_ACTIONS: 'GetOlderActions', GET_NEWER_ACTIONS: 'GetNewerActions', EXPAND_URL_PREVIEW: 'ExpandURLPreview', @@ -1487,6 +1488,7 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_ENABLE_PAYMENTS_PAGE]: null; [READ_COMMANDS.OPEN_SEARCH_PAGE]: Parameters.OpenSearchPageParams; [READ_COMMANDS.SEARCH]: Parameters.SearchParams; + [READ_COMMANDS.GET_TRANSACTIONS_CONVERTED_AMOUNT]: Parameters.GetTransactionsConvertedAmountParams; [READ_COMMANDS.BEGIN_SIGNIN]: Parameters.BeginSignInParams; [READ_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN]: Parameters.SignInWithShortLivedAuthTokenParams; [READ_COMMANDS.SIGN_IN_WITH_SUPPORT_AUTH_TOKEN]: Parameters.SignInWithSupportAuthTokenParams; diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 08ef665e0b32..ca330562efe5 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -437,6 +437,7 @@ const onyxKeysToMaskFragileData = new Set([ ONYXKEYS.SAVED_SEARCHES, ONYXKEYS.SCHEDULE_CALL_DRAFT, ONYXKEYS.SCREEN_SHARE_REQUEST, + ONYXKEYS.SEARCH_FOOTER_CONVERSION, ONYXKEYS.SEARCH_QUERY_BY_HASH, ONYXKEYS.SHARE_BANK_ACCOUNT, ONYXKEYS.SHARE_TEMP_FILE, diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index a3df1df6db4a..d698c1041aba 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -80,6 +80,7 @@ import type { import type {PaymentInformation} from '@src/types/onyx/LastPaymentMethod'; import type {ConnectionName} from '@src/types/onyx/Policy'; import type {AnyOnyxUpdate, OnyxData} from '@src/types/onyx/Request'; +import type SearchFooterConversion from '@src/types/onyx/SearchFooterConversion'; import type {SearchResultDataType} from '@src/types/onyx/SearchResults'; import type Nullable from '@src/types/utils/Nullable'; @@ -925,6 +926,28 @@ function handlePreventSearchAPI(hash: number | undefined) { }; } +/** + * Builds the backend-facing query from a client queryJSON: strips client-only fields and rewrites client-side-sorted + * columns to a backend-supported date sort. + */ +function getBackendQueryJSON(queryJSON: Readonly) { + const {flatFilters, limit, ...queryJSONWithoutFlatFilters} = queryJSON; + const backendQueryJSON = shouldUseBackendDateSortFallback(queryJSON.sortBy) + ? { + ...queryJSONWithoutFlatFilters, + sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, + inputQuery: buildSearchQueryString({ + ...queryJSON, + sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, + }), + rawFilterList: queryJSONWithoutFlatFilters.rawFilterList?.map((filter) => + filter.key === CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_BY ? {...filter, value: CONST.SEARCH.TABLE_COLUMNS.DATE} : filter, + ), + } + : queryJSONWithoutFlatFilters; + return {backendQueryJSON, limit}; +} + function search({ queryJSON, searchKey, @@ -964,20 +987,7 @@ function search({ inFlightSearchRequests.add(dedupeKey); const {optimisticData, successData, finallyData, failureData} = getOnyxLoadingData(queryJSON.hash, queryJSON, offset, isOffline, true, shouldCalculateTotals); - const {flatFilters, limit, ...queryJSONWithoutFlatFilters} = queryJSON; - const backendQueryJSON = shouldUseBackendDateSortFallback(queryJSON.sortBy) - ? { - ...queryJSONWithoutFlatFilters, - sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, - inputQuery: buildSearchQueryString({ - ...queryJSON, - sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, - }), - rawFilterList: queryJSONWithoutFlatFilters.rawFilterList?.map((filter) => - filter.key === CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_BY ? {...filter, value: CONST.SEARCH.TABLE_COLUMNS.DATE} : filter, - ), - } - : queryJSONWithoutFlatFilters; + const {backendQueryJSON, limit} = getBackendQueryJSON(queryJSON); const query = { ...backendQueryJSON, searchKey, @@ -1060,6 +1070,72 @@ function search({ return waitForWrites(READ_COMMANDS.SEARCH).then(startRequest).catch(handleSearchError); } +/** + * Fetches converted footer-total figures for the Search footer currency picker. The Auth command merges the + * results into the SEARCH_FOOTER_CONVERSION cache via onyxData (nested under the target currency), leaving the + * live search snapshot untouched: + * - transactionIDList: each transaction's converted amount. + * - reportIDList: each report's converted total (the Reports search). + * - neither: the whole-search converted total/count + the first page's per-transaction amounts. + * Callers should check the cache first to avoid redundant requests. + */ +function getFooterConvertedAmounts({ + queryJSON, + searchKey, + targetCurrency, + transactionIDList, + reportIDList, + sources, +}: { + queryJSON: Readonly; + searchKey: SearchKey | undefined; + targetCurrency: string; + transactionIDList?: string; + reportIDList?: string; + /** Default-currency source figures to stamp the requested conversions against (for stale detection on edit). */ + sources?: SearchFooterConversion['sources']; +}) { + if (!targetCurrency) { + return; + } + + // searchKey changes what the backend query matches (e.g. unapprovedCash excludes card expenses), so it must be + // sent exactly as search() sends it or the converted totals cover a different expense set than the snapshot. + const {backendQueryJSON} = getBackendQueryJSON(queryJSON); + const jsonQuery = serializeQueryJSONForBackend({ + ...backendQueryJSON, + searchKey, + filters: backendQueryJSON.filters ?? null, + }); + + read( + READ_COMMANDS.GET_TRANSACTIONS_CONVERTED_AMOUNT, + { + jsonQuery, + targetCurrency, + ...(transactionIDList && {transactionIDList}), + ...(reportIDList && {reportIDList}), + }, + { + // Stamp the source figures this request converts (and clear any prior failure for this currency) so a later + // edit that moves them is detected as stale and the footer can retry. The command merges its converted + // figures into the same key, so the stamp and the converted value live side by side. + optimisticData: [{onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.SEARCH_FOOTER_CONVERSION, value: {...(sources && {sources}), failedCurrencies: {[targetCurrency]: null}}}], + // A failed read leaves no converted value, so record the failure; the footer then falls back to the default + // total instead of the stale converted value (or a skeleton that would stay until the next edit/reconnect). + failureData: [{onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.SEARCH_FOOTER_CONVERSION, value: {failedCurrencies: {[targetCurrency]: true}}}], + }, + ); +} + +/** + * Clears the footer-currency conversion cache. The converted figures are ephemeral, session-scoped display + * data, so they are dropped when leaving Search rather than persisted across sessions. + */ +function clearFooterConversion() { + Onyx.set(ONYXKEYS.SEARCH_FOOTER_CONVERSION, null); +} + function submitMoneyRequestOnSearch( hash: number, reportList: Report[], @@ -1879,6 +1955,8 @@ function setOptimisticDataForTransactionThreadPreview(item: TransactionListItemT export { saveSearch, search, + getFooterConvertedAmounts, + clearFooterConversion, rejectMoneyRequestsOnSearch, exportSearchItemsToCSV, queueExportSearchItemsToCSV, diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 27300f3fd1d9..4a350b91b516 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -16,7 +16,7 @@ import useSearchPageSetup from '@hooks/useSearchPageSetup'; import useThemeStyles from '@hooks/useThemeStyles'; import {searchInServer} from '@libs/actions/Report'; -import {search} from '@libs/actions/Search'; +import {clearFooterConversion, search} from '@libs/actions/Search'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; @@ -94,6 +94,9 @@ function SearchPage({route}: SearchPageProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Converted footer totals are ephemeral, session-scoped display data, so drop them when leaving Search. + useEffect(() => () => clearFooterConversion(), []); + const prevIsLoading = usePrevious(currentSearchResults?.isLoading); useEffect(() => { diff --git a/src/types/onyx/SearchFooterConversion.ts b/src/types/onyx/SearchFooterConversion.ts new file mode 100644 index 000000000000..1be61781b6fd --- /dev/null +++ b/src/types/onyx/SearchFooterConversion.ts @@ -0,0 +1,63 @@ +/** A converted amount keyed by target currency code */ +type ConvertedAmountByCurrency = Record; + +/** A whole-search converted total in a single target currency */ +type ConvertedTotal = { + /** Converted total amount */ + total: number; + + /** Number of transactions the total covers */ + count: number; +}; + +/** A whole-search converted total keyed by target currency code */ +type ConvertedTotalByCurrency = Record; + +/** + * The default-currency source figure a cached conversion was computed from, keyed by target currency code. The + * footer stamps this when it requests a conversion and compares it to the live snapshot value on every render; a + * mismatch (e.g. after an inline edit) means the cached conversion is stale and must be fetched again. + */ +type SourceByCurrency = Record; + +/** Cache of converted footer-total figures for the Search footer currency picker, populated by GetTransactionsConvertedAmount */ +type SearchFooterConversion = { + /** Per-transaction converted amounts, keyed by transaction ID then by target currency */ + transactions?: Record; + + /** Per-report converted totals, keyed by report ID then by target currency */ + reports?: Record; + + /** Per-group converted totals (grouped searches), keyed by group key then by target currency */ + groups?: Record; + + /** Whole-search converted totals, keyed by search query hash then by target currency */ + searchTotals?: Record; + + /** + * Target currencies whose most recent conversion request failed. Set when a `GetTransactionsConvertedAmount` read + * errors and cleared when a fresh request for that currency is issued; lets the footer drop the loading state and + * fall back to the default total instead of a skeleton that would never resolve. + */ + failedCurrencies?: Record; + + /** + * Source figures the footer stamps the above conversions against, mirroring their keys (transaction ID / report ID / + * group key / search hash, then target currency). Compared to the live snapshot to detect stale conversions. + */ + sources?: { + /** Source figure each transaction conversion was computed from */ + transactions?: Record; + + /** Source figure each report conversion was computed from */ + reports?: Record; + + /** Source figure each group conversion was computed from */ + groups?: Record; + + /** Source figure each whole-search grand total was computed from */ + searchTotals?: Record; + }; +}; + +export default SearchFooterConversion; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 2210cb5c7772..138cd89b04e4 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -166,6 +166,7 @@ import type {SaveSearch} from './SaveSearch'; import type ScheduleCallDraft from './ScheduleCallDraft'; import type ScreenShareRequest from './ScreenShareRequest'; import type SearchContext from './SearchContext'; +import type SearchFooterConversion from './SearchFooterConversion'; import type SearchResults from './SearchResults'; import type SearchSidebar from './SearchSidebar'; import type SecurityGroup from './SecurityGroup'; @@ -383,6 +384,7 @@ export type { SaveSearch, RecentSearchItem, SearchContext, + SearchFooterConversion, SearchSidebar, ImportedSpreadsheet, BankAccountShareDetails, diff --git a/tests/unit/Search/searchTotalsLoadingDataTest.ts b/tests/unit/Search/searchTotalsLoadingDataTest.ts index 3bb2877c8852..b4f667bad3a6 100644 --- a/tests/unit/Search/searchTotalsLoadingDataTest.ts +++ b/tests/unit/Search/searchTotalsLoadingDataTest.ts @@ -39,12 +39,18 @@ type SearchRequestData = { }>; }; +type SearchRequestParams = { + hash: number; + jsonQuery: string; +}; + function getMakeRequestWithSideEffectsMock() { return makeRequestWithSideEffects as unknown as { mock: { - calls: Array<[unknown, unknown, SearchRequestData?]>; + calls: Array<[unknown, SearchRequestParams, SearchRequestData?]>; }; mockResolvedValue: (value: {onyxData: Array<{value: {search: {offset: number; hasMoreResults: boolean}; data: Record}}>; jsonCode: number}) => void; + mockReturnValue: (value: Promise) => void; }; } @@ -61,6 +67,16 @@ function getSearchLoadingUpdateForHash(hash: number) { return optimisticData.find((update) => update.key === `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}` && !!update.value?.search?.isLoading)?.value?.search; } +function getLastSearchRequestParams() { + const makeRequestWithSideEffectsMock = getMakeRequestWithSideEffectsMock(); + const [, requestParams] = makeRequestWithSideEffectsMock.mock.calls.at(-1) ?? []; + if (!requestParams) { + throw new Error('Search request params should be defined'); + } + + return requestParams; +} + describe('search loading totals handling', () => { beforeEach(() => { jest.clearAllMocks(); @@ -134,4 +150,37 @@ describe('search loading totals handling', () => { expect(loadingSearchData?.total).toBeUndefined(); expect(loadingSearchData?.currency).toBeUndefined(); }); + + it('dedupes concurrent search requests by hash and offset', async () => { + const queryJSON = getQueryJSON(); + let resolveSearch: (value: unknown) => void = () => {}; + const pendingSearch = new Promise((resolve) => { + resolveSearch = resolve; + }); + getMakeRequestWithSideEffectsMock().mockReturnValue(pendingSearch); + + const firstSearch = search({ + queryJSON, + searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, + offset: 0, + shouldCalculateTotals: true, + isLoading: false, + skipWaitForWrites: true, + }); + const secondSearch = search({ + queryJSON, + searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, + offset: 0, + shouldCalculateTotals: true, + isLoading: false, + skipWaitForWrites: true, + }); + + expect(makeRequestWithSideEffects).toHaveBeenCalledTimes(1); + expect(JSON.parse(getLastSearchRequestParams().jsonQuery)).toMatchObject({shouldCalculateTotals: true}); + expect(secondSearch).toBeUndefined(); + + resolveSearch({jsonCode: CONST.JSON_CODE.SUCCESS}); + await firstSearch; + }); });