diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index d69c87a5617e..7223653c6fe1 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -700,6 +700,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', @@ -1535,6 +1538,7 @@ type OnyxValuesMapping = { [ONYXKEYS.SAVED_SEARCHES]: OnyxTypes.SaveSearch; [ONYXKEYS.NVP_HAS_SEEDED_MY_EXPENSES_SEARCH]: boolean; [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/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 05cd15942cbc..24ea2cb8d7d9 100644 --- a/src/components/Search/FilterComponents/ListFilterViewWrapper.tsx +++ b/src/components/Search/FilterComponents/ListFilterViewWrapper.tsx @@ -16,19 +16,21 @@ type ListFilterWrapperProps = { isSearchable?: boolean; isNegatable?: boolean; extraHeight?: number; + shouldUseFixedPopoverHeight?: boolean; }; -function ListFilterView({children, itemCount, itemHeight, hasTitle = true, hasHeader, isSearchable, isNegatable, extraHeight}: ListFilterWrapperProps) { +function ListFilterView({children, itemCount, itemHeight, hasTitle = true, hasHeader, isSearchable, isNegatable, 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; + + /** Whether the popover keeps a fixed height instead of growing with its content */ + shouldUseFixedPopoverHeight?: boolean; +}; + +/** + * Searchable single-select currency picker popup, used by the Search footer's total-spend currency selector + * and the Display filter's group-currency picker. + */ +function CurrencyPopup({label, onBackButtonPress, onChange, closeOverlay, value, defaultValue, searchPlaceholder, shouldShowList, shouldUseFixedPopoverHeight}: 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 d0a9d9ec5029..000000000000 --- a/src/components/Search/FilterDropdowns/GroupCurrencyPopup.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import type {SingleSelectItem} from '@components/Search/FilterComponents/SingleSelect'; - -import {useCurrencyListActions, useCurrencyListState} from '@hooks/useCurrencyList'; -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..65d26f90f0d1 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,144 @@ 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'; + +const noop = () => {}; 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 ( - | 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..45bbd3ac02bc --- /dev/null +++ b/src/components/Search/SearchPageFooterSkeleton.tsx @@ -0,0 +1,56 @@ +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; +const skeletonContainerStyle = {height: SKELETON_HEIGHT, width: SKELETON_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..bc978c26564b 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,450 @@ 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; +}; + +const EMPTY_REPORT_IDS: string[] = []; +const EMPTY_SOURCES: Record = {}; + +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 isGroupedSearch = !isReportsSearch && !!currentSearchQueryJSON?.groupBy; 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(() => { + if (!isReportsSearch) { + return EMPTY_REPORT_IDS; + } + return selectedReports.map((report) => report.reportID).filter((reportID): reportID is string => !!reportID); + }, [isReportsSearch, selectedReports]); + 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]); + + // Source figures for every loaded group, not just the selected ones. The grouped response caches every group's + // converted value, so stamping them all lets a later selection of another group reuse the cache instead of + // re-running the grouped query. Uses the same expense-signed figure as getEntrySource so a stamp always matches + // the live source the freshness checks compare against. + const loadedGroupSourceByKey = useMemo(() => { + const data = currentSearchResults?.data; + if (!isGroupedSearch || !data) { + return EMPTY_SOURCES; + } + const sources: Record = {}; + for (const key of Object.keys(data)) { + if (!isGroupEntry(key)) { + continue; + } + const group: unknown = data[key]; + if (group && typeof group === 'object' && 'total' in group && typeof group.total === 'number') { + sources[key] = -Math.abs(group.total); + } + } + return sources; + }, [currentSearchResults?.data, isGroupedSearch]); + const reportSourceByID = useMemo(() => { + if (!isReportsSearch) { + return EMPTY_SOURCES; + } + const sources: Record = {}; + for (const report of selectedReports) { + if (report.reportID) { + sources[report.reportID] = report.total; + } + } + return sources; + }, [isReportsSearch, 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], + ); + + // A conversion has been requested for the current live source when the stamp (written optimistically at request + // time) matches it — the converted value may still be in flight. The fetch effect fires on this weaker predicate, + // not on freshness: freshness also requires the value, so using it would fire a duplicate request when the + // effect re-runs off its own optimistic stamp merge. + const wasTransactionRequested = useCallback( + (transactionID: string, currency: string) => conversionSources?.transactions?.[transactionID]?.[currency] === transactionSourceByID[transactionID], + [conversionSources, transactionSourceByID], + ); + const wasGroupRequested = useCallback((key: string, currency: string) => conversionSources?.groups?.[key]?.[currency] === groupSourceByKey[key], [conversionSources, groupSourceByKey]); + const wasReportRequested = useCallback( + (reportID: string, currency: string) => conversionSources?.reports?.[reportID]?.[currency] === reportSourceByID[reportID], + [conversionSources, 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; + const wasSearchTotalRequested = !!selectedCurrency && metadataTotal !== undefined && 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 no request has covered what the footer needs. + // Each request stamps the source figures it converts, so the requested checks keep this to one request per + // out-of-coverage change (or per edit) rather than one per checkbox or render. + 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) { + // Request only the reports without a covering request: items already converted (or in flight) keep + // their cached figures, so growing a selection converts just the delta. + const reportIDsToConvert = selectedReportIDs.filter((reportID) => !wasReportRequested(reportID, selectedCurrency)); + if (reportIDsToConvert.length > 0) { + getFooterConvertedAmounts({ + queryJSON: currentSearchQueryJSON, + searchKey: currentSearchKey, + targetCurrency: selectedCurrency, + reportIDList: reportIDsToConvert.join(','), + sources: {reports: Object.fromEntries(reportIDsToConvert.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. Stamp every loaded group (selected ones from their entries, so + // the request always settles its own fire condition) — later selections of other groups then reuse the + // cached response instead of re-running the grouped query. + if (selectedGroupKeys.some((key) => !wasGroupRequested(key, selectedCurrency))) { + const groupSources = {...loadedGroupSourceByKey, ...groupSourceByKey}; + getFooterConvertedAmounts({ + queryJSON: currentSearchQueryJSON, + searchKey: currentSearchKey, + targetCurrency: selectedCurrency, + sources: {groups: Object.fromEntries(Object.entries(groupSources).map(([key, source]) => [key, {[selectedCurrency]: source}]))}, + }); + } + + // Individually-selected transactions convert by transaction ID (the loose rows in a grouped view, or the + // whole selection on a flat search). Request only the ones without a covering request, so growing a + // selection converts just the delta. + const transactionIDsToConvert = selectedTransactionIDs.filter((transactionID) => !wasTransactionRequested(transactionID, selectedCurrency)); + if (transactionIDsToConvert.length > 0) { + getFooterConvertedAmounts({ + queryJSON: currentSearchQueryJSON, + searchKey: currentSearchKey, + targetCurrency: selectedCurrency, + transactionIDList: transactionIDsToConvert.join(','), + sources: { + transactions: Object.fromEntries(transactionIDsToConvert.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 (!wasSearchTotalRequested) { + getFooterConvertedAmounts({ + queryJSON: currentSearchQueryJSON, + searchKey: currentSearchKey, + targetCurrency: selectedCurrency, + sources: metadataTotal !== undefined ? {searchTotals: {[currentSearchHash]: {[selectedCurrency]: metadataTotal}}} : undefined, + }); + } + }, [ + areAllSelectedConverted, + currentSearchHash, + currentSearchKey, + currentSearchQueryJSON, + groupSourceByKey, + hasCustomFooterCurrency, + loadedGroupSourceByKey, + wasGroupRequested, + isOffline, + wasReportRequested, + isReportsSearch, + wasSearchTotalRequested, + wasTransactionRequested, + 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 0fb7bd52215e..d21ee75d2aee 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 4e2fefdf7591..97f0009047be 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -1368,6 +1368,7 @@ const READ_COMMANDS = { OPEN_SEARCH_CARD_FILTERS_PAGE: 'OpenSearchCardFiltersPage', OPEN_SEARCH_CATEGORY_FILTERS_PAGE: 'OpenSearchCategoryFiltersPage', SEARCH: 'Search', + GET_TRANSACTIONS_CONVERTED_AMOUNT: 'GetTransactionsConvertedAmount', GET_OLDER_ACTIONS: 'GetOlderActions', GET_NEWER_ACTIONS: 'GetNewerActions', EXPAND_URL_PREVIEW: 'ExpandURLPreview', @@ -1491,6 +1492,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 9e2fa5e229a1..46fbc0ea36aa 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -458,6 +458,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 2822d3339d20..1863da5cb217 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -81,6 +81,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'; @@ -1012,6 +1013,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 {exactMatchFilterKeys, 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, exactMatchFilterKeys}; +} + function search({ queryJSON, searchKey, @@ -1051,20 +1074,7 @@ function search({ inFlightSearchRequests.add(dedupeKey); const {optimisticData, successData, finallyData, failureData} = getOnyxLoadingData(queryJSON.hash, queryJSON, offset, isOffline, true, shouldCalculateTotals); - const {exactMatchFilterKeys, 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, exactMatchFilterKeys} = getBackendQueryJSON(queryJSON); const query = { ...backendQueryJSON, searchKey, @@ -1153,6 +1163,75 @@ 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, exactMatchFilterKeys} = getBackendQueryJSON(queryJSON); + const jsonQuery = serializeQueryJSONForBackend( + { + ...backendQueryJSON, + searchKey, + filters: backendQueryJSON.filters ?? null, + }, + exactMatchFilterKeys ? new Set(exactMatchFilterKeys) : undefined, + ); + + 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[], @@ -2001,6 +2080,8 @@ export { saveSearch, seedMyExpensesSearch, search, + getFooterConvertedAmounts, + clearFooterConversion, rejectMoneyRequestsOnSearch, exportSearchItemsToCSV, queueExportSearchItemsToCSV, diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index cd65dabf2294..f42bee6a3d43 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -17,7 +17,7 @@ import useSeedMyExpensesSearch from '@hooks/useSeedMyExpensesSearch'; 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'; import {isSearchDataLoaded} from '@libs/SearchUIUtils'; @@ -98,6 +98,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 efe01b584d98..bd61f50897aa 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -169,6 +169,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'; @@ -390,6 +391,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; + }); }); diff --git a/tests/unit/SearchActionsTest.ts b/tests/unit/SearchActionsTest.ts index 2f4467ab051c..cdb46e7b9b67 100644 --- a/tests/unit/SearchActionsTest.ts +++ b/tests/unit/SearchActionsTest.ts @@ -1,6 +1,8 @@ -import {getExportTemplates, queueExportSearchItemsToCSV, queueExportSearchWithTemplate} from '@libs/actions/Search'; -import {write} from '@libs/API'; -import {WRITE_COMMANDS} from '@libs/API/types'; +import {getExportTemplates, getFooterConvertedAmounts, queueExportSearchItemsToCSV, queueExportSearchWithTemplate} from '@libs/actions/Search'; +import {read, write} from '@libs/API'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import type {SearchKey} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -16,6 +18,7 @@ jest.mock('@libs/Network/enhanceParameters', () => ({ })); const mockWrite = jest.mocked(write); +const mockRead = jest.mocked(read); function getWriteOptions(): {optimisticData: AnyOnyxUpdate[]; failureData: AnyOnyxUpdate[]} { const options = mockWrite.mock.calls.at(-1)?.at(2); @@ -32,6 +35,30 @@ function getWriteOptions(): {optimisticData: AnyOnyxUpdate[]; failureData: AnyOn return {optimisticData: options.optimisticData, failureData: options.failureData}; } +function getReadOptions(): {optimisticData: AnyOnyxUpdate[]; failureData: AnyOnyxUpdate[]} { + const options = mockRead.mock.calls.at(-1)?.at(2); + if ( + !options || + typeof options !== 'object' || + !('optimisticData' in options) || + !Array.isArray(options.optimisticData) || + !('failureData' in options) || + !Array.isArray(options.failureData) + ) { + throw new Error('read was not called with optimistic options'); + } + return {optimisticData: options.optimisticData, failureData: options.failureData}; +} + +function getQueryJSON() { + const queryJSON = buildSearchQueryJSON(''); + if (!queryJSON) { + throw new Error('Query JSON should be defined for test setup'); + } + + return queryJSON; +} + describe('queueExportSearchItemsToCSV', () => { beforeEach(() => jest.clearAllMocks()); @@ -122,6 +149,63 @@ describe('queueExportSearchWithTemplate', () => { }); }); +describe('getFooterConvertedAmounts', () => { + beforeEach(() => jest.clearAllMocks()); + + it('does not call API.read when the target currency is empty', () => { + getFooterConvertedAmounts({queryJSON: getQueryJSON(), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES as SearchKey, targetCurrency: ''}); + + expect(mockRead).not.toHaveBeenCalled(); + }); + + it('requests the whole-search conversion when no transaction or report IDs are given', () => { + getFooterConvertedAmounts({queryJSON: getQueryJSON(), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES as SearchKey, targetCurrency: 'EUR'}); + + expect(mockRead).toHaveBeenCalledWith( + READ_COMMANDS.GET_TRANSACTIONS_CONVERTED_AMOUNT, + expect.objectContaining({targetCurrency: 'EUR'}), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect.objectContaining({optimisticData: expect.any(Array), failureData: expect.any(Array)}), + ); + + const params = mockRead.mock.calls.at(-1)?.at(1); + expect(params).not.toHaveProperty('transactionIDList'); + expect(params).not.toHaveProperty('reportIDList'); + }); + + it('scopes the request to the given transaction and report IDs', () => { + getFooterConvertedAmounts({ + queryJSON: getQueryJSON(), + searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES as SearchKey, + targetCurrency: 'EUR', + transactionIDList: '1,2', + reportIDList: '3,4', + }); + + expect(mockRead).toHaveBeenCalledWith(READ_COMMANDS.GET_TRANSACTIONS_CONVERTED_AMOUNT, expect.objectContaining({transactionIDList: '1,2', reportIDList: '3,4'}), expect.anything()); + }); + + it('optimistically stamps the sources and clears any prior failure for the target currency', () => { + const sources = {transactions: {transaction1: {USD: 42.5}}}; + + getFooterConvertedAmounts({queryJSON: getQueryJSON(), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES as SearchKey, targetCurrency: 'EUR', sources}); + + const {optimisticData} = getReadOptions(); + const conversionUpdate = optimisticData.find((update) => update.key === ONYXKEYS.SEARCH_FOOTER_CONVERSION); + expect(conversionUpdate).toBeDefined(); + expect(conversionUpdate?.value).toEqual({sources, failedCurrencies: {EUR: null}}); + }); + + it('marks the target currency as failed on failureData', () => { + getFooterConvertedAmounts({queryJSON: getQueryJSON(), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES as SearchKey, targetCurrency: 'EUR'}); + + const {failureData} = getReadOptions(); + const conversionUpdate = failureData.find((update) => update.key === ONYXKEYS.SEARCH_FOOTER_CONVERSION); + expect(conversionUpdate).toBeDefined(); + expect(conversionUpdate?.value).toEqual({failedCurrencies: {EUR: true}}); + }); +}); + describe('getExportTemplates', () => { const translate = translateLocal; const localeCompare = (first: string, second: string) => first.localeCompare(second);