diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 8782c225bc3f..9206ab6313fc 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1189,6 +1189,7 @@ const CONST = { INSIGHTS_PAGE: 'insightsPage', INSIGHTS_COMPARE: 'insightsCompare', PAYMENT_HISTORY: 'paymentHistory', + SPEND_FOOTER_SELECTORS: 'spendFooterSelectors', SUPPORT_TICKET: 'supportTicket', ANCHORED_FIELD_DROPDOWNS: 'anchoredFieldDropdowns', RESIZABLE_TABLE_COLUMNS: 'resizableTableColumns', @@ -8243,6 +8244,19 @@ const CONST = { LINE: 'line', PIE: 'pie', }, + /** Which of the counts every search returns the Spend footer displays. Applied client-side, so it never retriggers a search. */ + FOOTER_COUNT: { + EXPENSES: 'expenses', + REPORTS: 'reports', + }, + /** Which aggregate the Spend footer displays as its total. Changing it retriggers the search, since the backend computes the aggregate. */ + FOOTER_TOTAL: { + TOTAL: 'total', + REIMBURSABLE: 'reimbursable', + NON_REIMBURSABLE: 'non-reimbursable', + BILLABLE: 'billable', + NON_BILLABLE: 'non-billable', + }, // Comparison modes for Insights queries, defined by the API. COMPARE: { PREVIOUS_PERIOD: 'previousPeriod', @@ -8305,6 +8319,12 @@ const CONST = { SUBMITTER_USER_ID: 'submitterUserID', SUBMITTER_PAYROLL_ID: 'submitterPayrollID', ORDER_DEAL_NUMBERS: 'orderDealNumbers', + // The Spend footer's display selections. They travel as filters because that is where the backend reads + // them from, but they never narrow the result set: only `footerTotal` changes the response, by swapping + // which aggregate comes back as the search total. + FOOTER_COUNT: 'footerCount', + FOOTER_TOTAL: 'footerTotal', + FOOTER_CURRENCY: 'footerCurrency', }, REPORT_FIELD: { // All report fields start with this, so use this to check if a search key is a report field @@ -8390,6 +8410,9 @@ const CONST = { ORDER_DEAL_NUMBERS: 'order-deal-numbers', COLUMNS: 'columns', LIMIT: 'limit', + FOOTER_COUNT: 'footer-count', + FOOTER_TOTAL: 'footer-total', + FOOTER_CURRENCY: 'footer-currency', COMPARE: 'compare', }, get SEARCH_USER_FRIENDLY_VALUES_MAP() { @@ -9657,6 +9680,9 @@ const CONST = { FILTER_GROUP_CURRENCY: 'Search-FilterGroupCurrency', FILTER_VIEW: 'Search-FilterView', FILTER_LIMIT: 'Search-FilterLimit', + FOOTER_COUNT: 'Search-FooterCount', + FOOTER_TOTAL: 'Search-FooterTotal', + FOOTER_CURRENCY: 'Search-FooterCurrency', ADVANCED_FILTERS_BUTTON: 'Search-AdvancedFiltersButton', COLUMNS_BUTTON: 'Search-ColumnsButton', SELECT_ALL_CHECKBOX: 'Search-SelectAllCheckbox', diff --git a/src/components/Search/FilterDropdowns/SearchFooterPopup.tsx b/src/components/Search/FilterDropdowns/SearchFooterPopup.tsx new file mode 100644 index 000000000000..1db5eddc90a5 --- /dev/null +++ b/src/components/Search/FilterDropdowns/SearchFooterPopup.tsx @@ -0,0 +1,176 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import ScrollView from '@components/ScrollView'; +import type {SingleSelectItem} from '@components/Search/FilterComponents/SingleSelect'; +import type {SearchFooterCount, SearchFooterTotal} from '@components/Search/types'; + +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getFooterTotalItems} from '@libs/SearchUIUtils'; + +import CONST from '@src/CONST'; + +import React, {useState} from 'react'; + +import CurrencyPopup from './CurrencyPopup'; +import SingleSelectPopup from './SingleSelectPopup'; + +/** Which selector the menu has drilled into, or null while it is showing its list of rows */ +type SelectedFooterFilter = + | typeof CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_COUNT + | typeof CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_TOTAL + | typeof CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_CURRENCY + | null; + +type SearchFooterPopupProps = { + /** The count the footer is currently displaying. Undefined hides the count row, e.g. while the footer describes a selection. */ + countType: SearchFooterCount | undefined; + + /** The count to fall back to when the count selector is reset */ + defaultCountType: SearchFooterCount; + + /** The total the footer is currently displaying. Undefined hides the total row, e.g. on an empty result set. */ + totalType: SearchFooterTotal | undefined; + + /** Whether the Total row is offered. A whole-group selection has no breakdown to pick, but still names its figure. */ + shouldShowTotalSelector: boolean; + + /** Whether the total and currency rows are waiting on a search, which recomputes both server-side */ + isTotalLoading: boolean; + + /** The currency the footer's total is currently displayed in */ + currency: string | undefined; + + /** The currency to fall back to when the currency selector is reset */ + defaultCurrency: string | undefined; + + /** Whether the popover is open. Keeps the currency list mounted only while it can be seen. */ + isExpanded: boolean; + + /** Function to call to close the popover */ + closeOverlay: () => void; + + /** Function to call when the displayed count changes */ + onCountChange: (countType: SearchFooterCount) => void; + + /** Function to call when the displayed total changes */ + onTotalChange: (totalType: SearchFooterTotal) => void; + + /** Function to call when the displayed currency changes */ + onCurrencyChange: (item: SingleSelectItem | undefined) => void; +}; + +/** + * The Spend footer's display menu: a row per selector, each opening its own sub-popup with Apply and Reset. Mirrors + * the Display menu in the search header, so the two behave the same way. + */ +function SearchFooterPopup({ + countType, + defaultCountType, + totalType, + shouldShowTotalSelector, + isTotalLoading, + currency, + defaultCurrency, + isExpanded, + closeOverlay, + onCountChange, + onTotalChange, + onCurrencyChange, +}: SearchFooterPopupProps) { + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const styles = useThemeStyles(); + const [selectedFooterFilter, setSelectedFooterFilter] = useState(null); + + const countItems: Array> = [ + {text: translate('common.expenses'), value: CONST.SEARCH.FOOTER_COUNT.EXPENSES}, + {text: translate('common.reports'), value: CONST.SEARCH.FOOTER_COUNT.REPORTS}, + ]; + const totalItems = getFooterTotalItems(translate); + + if (!selectedFooterFilter) { + return ( + + {!!countType && ( + item.value === countType)?.text} + onPress={() => setSelectedFooterFilter(CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_COUNT)} + sentryLabel={CONST.SENTRY_LABEL.SEARCH.FOOTER_COUNT} + /> + )} + {shouldShowTotalSelector && !!totalType && ( + item.value === totalType)?.text} + onPress={() => setSelectedFooterFilter(CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_TOTAL)} + sentryLabel={CONST.SENTRY_LABEL.SEARCH.FOOTER_TOTAL} + /> + )} + setSelectedFooterFilter(CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_CURRENCY)} + sentryLabel={CONST.SENTRY_LABEL.SEARCH.FOOTER_CURRENCY} + /> + + ); + } + + const goBack = () => setSelectedFooterFilter(null); + + if (selectedFooterFilter === CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_COUNT) { + return ( + item.value === countType)} + label={translate('common.count')} + onBackButtonPress={goBack} + closeOverlay={closeOverlay} + // Reset falls back to the count that matches the search type, which is also what an unset selection shows. + onChange={(item) => onCountChange(item?.value ?? defaultCountType)} + defaultValue={defaultCountType} + /> + ); + } + + if (selectedFooterFilter === CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_TOTAL) { + return ( + item.value === totalType)} + label={translate('common.total')} + onBackButtonPress={goBack} + closeOverlay={closeOverlay} + onChange={(item) => onTotalChange(item?.value ?? CONST.SEARCH.FOOTER_TOTAL.TOTAL)} + defaultValue={CONST.SEARCH.FOOTER_TOTAL.TOTAL} + /> + ); + } + + return ( + + ); +} + +export default SearchFooterPopup; diff --git a/src/components/Search/SearchPageFooter.tsx b/src/components/Search/SearchPageFooter.tsx index a10b046ded9c..f4f2b77a4bbc 100644 --- a/src/components/Search/SearchPageFooter.tsx +++ b/src/components/Search/SearchPageFooter.tsx @@ -6,46 +6,85 @@ import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import {getFooterTotalItems} from '@libs/SearchUIUtils'; + import CONST from '@src/CONST'; +import type {LayoutChangeEvent} from 'react-native'; + 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 type {SearchFooterCount, SearchFooterTotal} from './types'; import CurrencyPopup from './FilterDropdowns/CurrencyPopup'; import FilterPopupButton from './FilterDropdowns/FilterPopupButton'; +import SearchFooterPopup from './FilterDropdowns/SearchFooterPopup'; import SearchPageFooterSkeleton from './SearchPageFooterSkeleton'; const noop = () => {}; type SearchPageFooterProps = { - /** Number of expenses represented by the footer total */ + /** Number of expenses or reports represented by the footer count */ count: number | undefined; + /** Which count the footer is displaying. Undefined renders the count as static text with no selector, e.g. while + * the footer describes a selection rather than the whole search. */ + countType: SearchFooterCount | undefined; + + /** The count the count selector falls back to when it is reset */ + defaultCountType: SearchFooterCount; + /** Total amount to display in the footer */ total: number | undefined; + /** Which total the footer is displaying. Undefined renders the plain total spend with no selector. */ + totalType: SearchFooterTotal | undefined; + /** Currency code for the displayed total */ currency: string | undefined; /** Currency code used when the footer currency is reset */ defaultCurrency: string | undefined; + /** Whether the display menu offers the total selector. The label follows `totalType` either way. */ + shouldShowTotalSelector: boolean; + /** Whether the footer total is currently refreshing */ isTotalLoading: boolean; /** Function to call when the footer currency changes */ onCurrencyChange: (currency: string) => void; + + /** Function to call when the displayed count changes */ + onCountChange: (countType: SearchFooterCount) => void; + + /** Function to call when the displayed total changes */ + onTotalChange: (totalType: SearchFooterTotal) => void; }; -function SearchPageFooter({count, total, currency, defaultCurrency, isTotalLoading, onCurrencyChange}: SearchPageFooterProps) { +function SearchPageFooter({ + count, + countType, + defaultCountType, + total, + totalType, + shouldShowTotalSelector, + currency, + defaultCurrency, + isTotalLoading, + onCurrencyChange, + onCountChange, + onTotalChange, +}: SearchPageFooterProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -55,14 +94,26 @@ function SearchPageFooter({count, total, currency, defaultCurrency, isTotalLoadi const icons = useMemoizedLazyExpensifyIcons(['DownArrow']); const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {isBetaEnabled} = usePermissions(); + const isFooterSelectorsEnabled = isBetaEnabled(CONST.BETAS.SPEND_FOOTER_SELECTORS); + + const [isFooterButtonFocused, setIsFooterButtonFocused] = useState(false); + // The height the real total last occupied, which is what the skeleton stands in at. + const [loadedTotalHeight, setLoadedTotalHeight] = useState(undefined); - const [isTotalButtonFocused, setIsTotalButtonFocused] = useState(false); + const handleTotalLayout = (event: LayoutChangeEvent) => { + const height = Math.round(event.nativeEvent.layout.height); + if (!height || height === loadedTotalHeight) { + return; + } + setLoadedTotalHeight(height); + }; const valueTextStyle = useMemo(() => (isOffline ? [styles.textLabelSupporting, styles.labelStrong] : [styles.labelStrong]), [isOffline, styles]); - // The SearchList registers a global Enter shortcut that opens the focused expense. While the total button is focused, - // claim Enter at top priority without bubbling so Enter only opens the currency popover instead of also opening the expense. - useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, noop, {isActive: isTotalButtonFocused, shouldBubble: false, shouldPreventDefault: false}); + // The SearchList registers a global Enter shortcut that opens the focused expense. While the footer button is focused, + // claim Enter at top priority without bubbling so Enter only opens the footer's display menu instead of also opening the expense. + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, noop, {isActive: isFooterButtonFocused, shouldBubble: false, shouldPreventDefault: false}); const handleCurrencyChange = (item: SingleSelectItem | undefined) => { if (isOffline) { @@ -100,8 +151,8 @@ function SearchPageFooter({count, total, currency, defaultCurrency, isTotalLoadi size={CONST.BUTTON_SIZE.SMALL} hoverStyles={styles.bgTransparent} onPress={props.onPress} - onFocus={() => setIsTotalButtonFocused(true)} - onBlur={() => setIsTotalButtonFocused(false)} + onFocus={() => setIsFooterButtonFocused(true)} + onBlur={() => setIsFooterButtonFocused(false)} > ); - return ( - - + if (!isFooterSelectorsEnabled) { + return ( + + + + {`${translate('common.expenses')}:`} + {count} + + {typeof total === 'number' && ( + + {`${translate('common.totalSpend')}:`} + + + )} + + {isTotalLoading && ( + + + + )} + + ); + } + + const renderFooterPopup: FilterPopupButtonProps['PopoverComponent'] = ({closeOverlay, isExpanded}) => ( + + ); + + const countLabel = translate(countType === CONST.SEARCH.FOOTER_COUNT.REPORTS ? 'common.reports' : 'common.expenses'); + + // The default reads as "Total spend" in the footer but as plain "Spend" inside the menu, where the row it sits in is + // already labelled Total. + const totalLabel = + totalType && totalType !== CONST.SEARCH.FOOTER_TOTAL.TOTAL ? getFooterTotalItems(translate).find((item) => item.value === totalType)?.text : translate('common.totalSpend'); + + const hasTotal = typeof total === 'number'; + const totalDisplayValue = convertToDisplayString(total, currency); + + // The whole footer is one control now, so its label has to carry both figures: the text inside a labelled button is + // not read out on its own. + const footerAccessibilityLabel = [`${countLabel}: ${count ?? ''}`, hasTotal ? `${totalLabel}: ${totalDisplayValue}` : undefined].filter(Boolean).join(', '); + + // The whole content is the trigger, not just the total: every figure it shows is changed from the same menu. + // It stays pressable while the total reloads and offline, since the count row needs no request; the rows that do + // wait inside the menu. + const footerButton = (props: ButtonComponentProps) => ( + + ); + + return ( + + + + ); } diff --git a/src/components/Search/SearchPageFooterSkeleton.tsx b/src/components/Search/SearchPageFooterSkeleton.tsx index bd28881c5a97..b3b0682965d1 100644 --- a/src/components/Search/SearchPageFooterSkeleton.tsx +++ b/src/components/Search/SearchPageFooterSkeleton.tsx @@ -4,41 +4,69 @@ import SkeletonViewContentLoader from '@components/SkeletonViewContentLoader'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; + 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 SKELETON_WIDTH = 110; +// Only used until the real total has been measured once, on the very first load. +const DEFAULT_SKELETON_HEIGHT = variables.lineHeightNormal; + +// The whole-row stand-in, which covers the count as well as the total. Only the footer without the display selectors +// uses it, where the row is hidden behind the skeleton rather than keeping its real count on screen. +const WHOLE_ROW_HEIGHT = 20; 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}; +const WHOLE_ROW_WIDTH = TOTAL_BAR_OFFSET + SKELETON_WIDTH; + +type SearchPageFooterSkeletonProps = { + /** Height to stand in at. It is the height the real total occupies, so swapping the two leaves the footer's own + * height untouched and nothing in the row shifts. */ + height?: number; + + /** Whether to stand in for the count as well as the total, which is what the footer without the selectors needs. */ + shouldStandInForWholeRow?: boolean; +}; -function SearchPageFooterSkeleton() { +/** Stands in for the Spend footer's total while a search recomputes it. The count beside it keeps its real value. */ +function SearchPageFooterSkeleton({height = DEFAULT_SKELETON_HEIGHT, shouldStandInForWholeRow = false}: SearchPageFooterSkeletonProps) { const styles = useThemeStyles(); const theme = useTheme(); + const skeletonHeight = shouldStandInForWholeRow ? WHOLE_ROW_HEIGHT : Math.max(height, BAR_HEIGHT); + const skeletonWidth = shouldStandInForWholeRow ? WHOLE_ROW_WIDTH : SKELETON_WIDTH; + const barVerticalOffset = (skeletonHeight - BAR_HEIGHT) / 2; return ( - + - - + {shouldStandInForWholeRow ? ( + <> + + + + ) : ( + + )} ); diff --git a/src/components/Search/SearchQueryProvider.tsx b/src/components/Search/SearchQueryProvider.tsx index 5cc94bf40967..63b7dd6ad6cb 100644 --- a/src/components/Search/SearchQueryProvider.tsx +++ b/src/components/Search/SearchQueryProvider.tsx @@ -2,10 +2,11 @@ import useCardFeedsForDisplay from '@hooks/useCardFeedsForDisplay'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLoadSearchCategoryData from '@hooks/useLoadSearchCategoryData'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import usePreviousDefined from '@hooks/usePreviousDefined'; import useRootNavigationState from '@hooks/useRootNavigationState'; -import {buildSearchQueryJSON, buildSearchQueryString, getSearchRootParamsFromRootState} from '@libs/SearchQueryUtils'; +import {buildSearchQueryJSON, buildSearchQueryString, getQueryWithoutFooterSelections, getSearchRootParamsFromRootState, hasFooterSelections} from '@libs/SearchQueryUtils'; import {getSuggestedSearches, getSuggestedSearchesVisibility} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; @@ -54,7 +55,11 @@ function SearchQueryProvider({children}: SearchQueryProviderProps) { const queryParams = useRootNavigationState((state) => selectSearchQueryParams(state ?? navigation.getState())); const {query: queryParam, rawQuery: rawQueryParam} = splitSearchQueryParams(usePreviousDefined(queryParams)); const definedQueryParam = queryParam ?? buildSearchQueryString(); - const currentSearchQueryJSON = buildSearchQueryJSON(definedQueryParam, rawQueryParam); + const parsedSearchQueryJSON = buildSearchQueryJSON(definedQueryParam, rawQueryParam); + const {isBetaEnabled} = usePermissions(); + const shouldDropFooterSelections = !isBetaEnabled(CONST.BETAS.SPEND_FOOTER_SELECTORS) && !!parsedSearchQueryJSON && hasFooterSelections(parsedSearchQueryJSON); + const currentSearchQueryJSON = + shouldDropFooterSelections && parsedSearchQueryJSON ? buildSearchQueryJSON(getQueryWithoutFooterSelections(parsedSearchQueryJSON), rawQueryParam) : parsedSearchQueryJSON; const shouldLoadCategoryData = currentSearchQueryJSON?.flatFilters.some((filter) => filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY) ?? false; useLoadSearchCategoryData({shouldLoad: shouldLoadCategoryData}); diff --git a/src/components/Search/SearchResultsProvider.tsx b/src/components/Search/SearchResultsProvider.tsx index 1b8d5620bba8..faf1668c4fc9 100644 --- a/src/components/Search/SearchResultsProvider.tsx +++ b/src/components/Search/SearchResultsProvider.tsx @@ -1,5 +1,7 @@ +import usePermissions from '@hooks/usePermissions'; import useTodoSearchResults from '@hooks/useTodoSearchResults'; +import {getFooterSelectionFromQuery} from '@libs/SearchQueryUtils'; import {getTransactionsByReportID, getViolationsFromSearchData, isTodoSearch} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; @@ -46,7 +48,12 @@ function SearchResultsProvider({children}: SearchResultsProviderProps) { const [snapshotSearchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${currentSearchHash}`); const shouldUseLiveData = !!currentSearchKey && isTodoSearch(currentRecentSearchHash, suggestedSearches); - const liveTodoData = useTodoSearchResults(shouldUseLiveData ? currentSearchKey : undefined); + // A to-do search has no snapshot to carry the backend's aggregate, so the breakdown the footer asks for is + // applied while the live totals are summed. Nothing is applied without the beta: the query is stripped of footer + // selections upstream, and this is the one place that would act on one anyway, so it checks for itself. + const {isBetaEnabled} = usePermissions(); + const {footerTotal} = isBetaEnabled(CONST.BETAS.SPEND_FOOTER_SELECTORS) ? getFooterSelectionFromQuery(currentSearchQueryJSON) : {footerTotal: undefined}; + const liveTodoData = useTodoSearchResults(shouldUseLiveData ? currentSearchKey : undefined, footerTotal); // If viewing a to-do search, use live Onyx data for the active category, otherwise return the snapshot data. // We do this so the results stay fresh as the user acts on reports, instead of showing a stale server snapshot. diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index 4b779c3cc15a..9edff463bad5 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -1,10 +1,14 @@ import useActivePolicy from '@hooks/useActivePolicy'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; -import {getFooterConvertedAmounts} from '@libs/actions/Search'; -import {isGroupEntry} from '@libs/SearchUIUtils'; +import {close} from '@libs/actions/Modal'; +import {getFooterConvertedAmounts, search} from '@libs/actions/Search'; +import Navigation from '@libs/Navigation/Navigation'; +import {buildSearchQueryJSON, getDefaultFooterCount, getFooterSelectionFromQuery, getQueryWithFooterSelection} from '@libs/SearchQueryUtils'; +import {doesTransactionMatchFooterTotal, isGroupEntry} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -13,9 +17,10 @@ import {getEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxEntry} from 'react-native-onyx'; +import {accountIDSelector} from '@selectors/Session'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import type {SelectedTransactionInfo, SelectedTransactions} from './types'; +import type {SearchFooterCount, SearchFooterTotal, SelectedTransactionInfo, SelectedTransactions} from './types'; import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionContext} from './SearchContext'; import SearchPageFooter from './SearchPageFooter'; @@ -34,11 +39,55 @@ type FooterCurrencyState = { /** Default currency captured for this search */ defaultCurrency: string | undefined; + + /** The query's currency when the pick was made. Once the query moves off it, the query's own currency wins */ + queryCurrency: string | undefined; }; +const EMPTY_FOOTER_SELECTION = {footerCount: undefined, footerTotal: undefined, footerCurrency: undefined}; const EMPTY_REPORT_IDS: string[] = []; const EMPTY_SOURCES: Record = {}; +type AnsweredFooterTotals = { + /** The breakdown the snapshot's `total` belongs to */ + answered: SearchFooterTotal; + + /** Each breakdown's last answered total */ + totals: Partial>; +}; + +// Breakdown totals answered per account and search hash, so offline can show the one the query asks for. Not on the +// snapshot (a search response replaces it) nor in the footer (tab switches remount it). In memory only. Keyed by account +// too, since a hash comes from the query text alone and another account signed in later shares it. +const answeredFooterTotalsByHash = new Map(); + +/** Test-only: clears the recorded answers between tests. */ +function resetAnsweredFooterTotalsForTesting() { + answeredFooterTotalsByHash.clear(); +} + +/** + * Records a settled answer and returns this search's answers. A function, not an inline read: React Compiler would + * cache a bare map read on the hash, which a footer switch never changes. + */ +function recordAnsweredFooterTotals( + accountID: number, + searchHash: number, + requestedFooterTotal: SearchFooterTotal, + total: number | undefined, + isSettledAnswer: boolean, +): AnsweredFooterTotals | undefined { + const key = `${accountID}_${searchHash}`; + if (isSettledAnswer) { + const previous = answeredFooterTotalsByHash.get(key); + answeredFooterTotalsByHash.set(key, { + answered: requestedFooterTotal, + totals: typeof total === 'number' ? {...previous?.totals, [requestedFooterTotal]: total} : (previous?.totals ?? {}), + }); + } + return answeredFooterTotalsByHash.get(key); +} + function getGroupCount(group: unknown): number { if (group && typeof group === 'object' && 'count' in group && typeof group.count === 'number') { return group.count; @@ -104,23 +153,35 @@ function areAllSelectedReportsConverted(selectedReportIDs: string[], isReportFre // footer — not SearchPage and the list it contains. function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { const {selectedTransactions, excludedTransactions = getEmptyObject(), areAllMatchingItemsSelected, selectedReports} = useSearchSelectionContext(); - const {currentSearchResults} = useSearchResultsContext(); + const {currentSearchResults, currentSearchTransactionsByReportID, shouldUseLiveData} = useSearchResultsContext(); const {currentSearchHash, currentSearchKey, currentSearchQueryJSON} = useSearchQueryContext(); const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, true, areAllMatchingItemsSelected); const {isOffline} = useNetwork(); const activePolicy = useActivePolicy(); + const [accountID = CONST.DEFAULT_NUMBER_ID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); // The server converts search figures to the active policy's currency when the query carries no explicit target. const searchTargetCurrency = activePolicy?.outputCurrency ?? CONST.CURRENCY.USD; const [footerCurrencyState, setFooterCurrencyState] = useState({ searchHash: undefined, selectedCurrency: undefined, defaultCurrency: undefined, + queryCurrency: undefined, }); + // Whether the backend is still answering the breakdown the footer asked for. The request keys no hash of its own — + // it refreshes the figures of the search already on screen — so the wait is held here and ends with the response. + const [isAwaitingBreakdownTotal, setIsAwaitingBreakdownTotal] = useState(false); + const {isBetaEnabled} = usePermissions(); + const isFooterSelectorsEnabled = isBetaEnabled(CONST.BETAS.SPEND_FOOTER_SELECTORS); + const footerSelection = isFooterSelectorsEnabled ? getFooterSelectionFromQuery(currentSearchQueryJSON) : EMPTY_FOOTER_SELECTION; // Onyx delivers an optimistic source stamp asynchronously. Bridge that short gap locally so a render caused by // selection reconciliation cannot dispatch the same report conversion twice before the stamp is observed. const pendingReportConversionSources = useRef>>({}); const isCurrentFooterState = footerCurrencyState.searchHash === currentSearchHash; - const selectedCurrency = isCurrentFooterState ? footerCurrencyState.selectedCurrency : undefined; + // The query carries the currency across a reload and into a saved search, so it is what an untouched footer starts + // from. This session's own pick bridges the moment until the query catches up with it. Once the query's currency + // moves (the pick landing, an edit, or a saved search on the same hash), the query wins. + const isPickAheadOfQuery = isCurrentFooterState && footerCurrencyState.queryCurrency === footerSelection.footerCurrency; + const selectedCurrency = (isPickAheadOfQuery ? footerCurrencyState.selectedCurrency : undefined) ?? footerSelection.footerCurrency; const defaultFooterCurrency = isCurrentFooterState ? footerCurrencyState.defaultCurrency : undefined; // The Auth command merges converted figures here (by transaction, report, group, and query hash, each nested @@ -143,9 +204,23 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { const isGroupedSearch = !isReportsSearch && !!currentSearchQueryJSON?.groupBy; const metadata = searchResults?.search; + + const requestedFooterTotal = footerSelection.footerTotal ?? CONST.SEARCH.FOOTER_TOTAL.TOTAL; + // A total stamped for another breakdown (a request dropped or overtaken, or a switch made while a selection was summed + // locally) is never shown under this one's label: the footer waits on it and asks again below. + const isSnapshotTotalForOtherBreakdown = isFooterSelectorsEnabled && !isOffline && !shouldUseLiveData && !!metadata?.footerTotal && metadata.footerTotal !== requestedFooterTotal; + const isSettledAnswer = !isOffline && !isAwaitingBreakdownTotal && !isSnapshotTotalForOtherBreakdown && !metadata?.isLoading && metadata?.hash === currentSearchHash; + + const answeredFooterTotals = recordAnsweredFooterTotals(accountID, currentSearchHash, requestedFooterTotal, metadata?.total, isSettledAnswer); + + // Offline, the snapshot may still hold another breakdown's total: show this breakdown's recorded one, or none. The stamp + // survives a reload, which empties the recorded answers. + const snapshotAnsweredFooterTotal = metadata?.footerTotal ?? answeredFooterTotals?.answered; + const isSnapshotTotalFromEarlierBreakdown = isOffline && !shouldUseLiveData && !!snapshotAnsweredFooterTotal && snapshotAnsweredFooterTotal !== requestedFooterTotal; const metadataCount = metadata?.count; + const metadataReportCount = metadata?.reportCount; const metadataCurrency = metadata?.currency; - const metadataTotal = metadata?.total; + const metadataTotal = isSnapshotTotalFromEarlierBreakdown ? answeredFooterTotals?.totals[requestedFooterTotal] : metadata?.total; const selectedTransactionsKeys = useMemo(() => Object.keys(selectedTransactions ?? {}), [selectedTransactions]); const excludedTransactionsKeys = useMemo(() => Object.keys(excludedTransactions), [excludedTransactions]); const isExpenseType = currentSearchQueryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE; @@ -159,6 +234,29 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { [currentSearchResults?.data, excludedTransactions, excludedTransactionsKeys, isExpenseType, isReportsSearch], ); + const excludedReportCount = useMemo(() => { + if (!isReportsSearch || excludedTransactionsKeys.length === 0) { + return 0; + } + const excludedKeys = new Set(excludedTransactionsKeys); + let count = 0; + for (const [reportID, reportTransactions] of currentSearchTransactionsByReportID ?? []) { + if (!reportID || reportTransactions.length === 0) { + continue; + } + if (reportTransactions.every((transaction) => excludedKeys.has(transaction.transactionID))) { + count += 1; + } + } + // An empty report has no expenses to exclude, so it is excluded under its own report key. + for (const key of excludedTransactionsKeys) { + if (key === excludedTransactions[key]?.reportID && !currentSearchTransactionsByReportID?.get(key)?.length) { + count += 1; + } + } + return count; + }, [currentSearchTransactionsByReportID, excludedTransactions, excludedTransactionsKeys, isReportsSearch]); + // 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), @@ -297,9 +395,46 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { areAllMatchingItemsSelected || (selectedTransactionsKeys.length > 0 && metadataCount !== undefined && selectedExpenseCount === metadataCount && !hasUnselectedCashBackGroup); const hasPartialSelection = selectedTransactionsKeys.length > 0 && !areAllSelectedForFooter; + // Both selectors stay available for whatever the footer is describing. A hand-picked selection is counted and summed + // from the selected rows themselves. Everything else (nothing selected, or a select-all with or without exclusions) + // is the server's figures, since a select-all covers rows that were never loaded and so cannot be counted here. + const defaultFooterCountType = getDefaultFooterCount(currentSearchQueryJSON?.type); + + // A group row selected as a whole is out of scope for now, pending the backend returning breakdowns per group. It + // reaches the selection one of two ways: as a single group entry when its expenses are not loaded, or as those + // expenses each flagged `isSelectedViaGroup` when they are. The flag is cleared as soon as one of them is toggled + // off, so a part-deselected group counts as an individual selection again and keeps both selectors. + // + // Only a grouped search is affected. A Reports search carries the same flag, because a report row is a group row + // there, but a report is that search's natural unit and its expenses are exactly what the selection holds. + const hasGroupSelection = + isGroupedSearch && + (selectedTransactionsKeys.some(isGroupEntry) || + excludedTransactionsKeys.some(isGroupEntry) || + selectedTransactionsKeys.some((key) => selectedTransactions[key]?.isSelectedViaGroup) || + excludedTransactionsKeys.some((key) => excludedTransactions[key]?.isSelectedViaGroup)); + + // The other case with no answer to give: the whole-search report count comes from the server, and a search that + // returned none of it (a grouped search, or a to-do search counting live Onyx data) has nothing to switch to. + const shouldShowCountSelector = + isFooterSelectorsEnabled && (isExpenseType || isReportsSearch) && !hasGroupSelection && (hasPartialSelection || (!shouldUseLiveData && typeof metadataReportCount === 'number')); + const footerCountType = shouldShowCountSelector ? (footerSelection.footerCount ?? defaultFooterCountType) : undefined; + + // An excluded collapsed group hides the breakdown's figure, so the selector stays to lead back to the plain total. + const hasExcludedGroupWithQueryBreakdown = excludedGroupKeys.length > 0 && !!footerSelection.footerTotal; + // An empty result set has no total to break down, so it keeps the plain total spend with no selector. + // Footer selections are only kept for expense and report searches, so other types keep the plain total, as on main. + const isFooterTotalSupported = isFooterSelectorsEnabled && (isExpenseType || isReportsSearch); + const shouldShowTotalSelector = isFooterTotalSupported && (!hasGroupSelection || hasExcludedGroupWithQueryBreakdown) && (!!metadataCount || selectedTransactionsKeys.length > 0); + // 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 shouldNameQueryBreakdown = isFooterTotalSupported && (shouldShowTotalSelector || !shouldUseClientTotal); + const footerTotalType = shouldNameQueryBreakdown ? (footerSelection.footerTotal ?? CONST.SEARCH.FOOTER_TOTAL.TOTAL) : undefined; + // No selector means no breakdown, which is the plain total, so the sums below never have to special-case it. + const footerTotalBreakdown = footerTotalType ?? CONST.SEARCH.FOOTER_TOTAL.TOTAL; const firstSelectedTransactionKey = selectedTransactionsKeys.at(0); const firstSelectedTransaction = firstSelectedTransactionKey ? selectedTransactions[firstSelectedTransactionKey] : undefined; const selectedTransactionDefaultCurrency = firstSelectedTransaction?.groupCurrency ?? firstSelectedTransaction?.currency; @@ -334,6 +469,12 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { const hasExcludedExpenses = isExpenseType && excludedTransactionsKeys.length > 0; const hasExcludedReports = isReportsSearch && excludedReportIDs.length > 0; + // A report's converted total has no breakdown inside it, so excluding one under a breakdown falls to the default + // currency, where the exclusion is summed from the report's own matching expenses. + const canConvertExcludedReports = !hasExcludedReports || footerTotalBreakdown === CONST.SEARCH.FOOTER_TOTAL.TOTAL; + // A collapsed group is excluded as one entry with no expenses to classify, so nothing tells how much of it falls in + // the breakdown. No figure is shown rather than one that still counts the group. + const hasExcludedGroupUnderBreakdown = excludedGroupKeys.length > 0 && footerTotalBreakdown !== CONST.SEARCH.FOOTER_TOTAL.TOTAL; const areAllExcludedConverted = useMemo(() => { if (!hasCustomFooterCurrency || !selectedCurrency || (!hasExcludedExpenses && !hasExcludedReports)) { return false; @@ -355,6 +496,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { !isOffline && !hasConversionFailed && hasCustomFooterCurrency && + (shouldUseClientTotal || (canConvertExcludedReports && !hasExcludedGroupUnderBreakdown)) && (shouldUseClientTotal ? hasConvertibleSelection && !areAllSelectedConverted : !isSearchTotalFresh || ((hasExcludedExpenses || hasExcludedReports) && !areAllExcludedConverted)); const shouldShowFooter = (!areAllMatchingItemsSelected && selectedTransactionsKeys.length > 0) || (shouldAllowFooterTotals && !!metadata?.count); @@ -504,16 +646,72 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { transactionSourceByID, ]); - const handleFooterCurrencyChange = useCallback( - (currency: string) => { - setFooterCurrencyState({ - searchHash: currentSearchHash, - selectedCurrency: currency, - defaultCurrency: effectiveDefaultCurrency, - }); - }, - [currentSearchHash, effectiveDefaultCurrency], - ); + // Every selection is written into the query, so it is restored with the search (and saved with a saved search). The + // query is rebuilt from the one on display rather than through the advanced-filters form: this footer is a + // self-subscribing leaf that re-renders on every checkbox press, and the form route would subscribe it to the whole + // policy collection. useSearchFilterSync writes the form from the query, so the form still follows. + const applyFooterSelection = (selection: {footerCount?: SearchFooterCount; footerTotal?: SearchFooterTotal; footerCurrency?: string}) => { + if (!currentSearchQueryJSON || !isFooterSelectorsEnabled) { + return; + } + + const nextQuery = getQueryWithFooterSelection(currentSearchQueryJSON, selection); + close(() => { + Navigation.setParams({q: nextQuery, rawQuery: undefined}); + }); + }; + + const handleFooterCurrencyChange = (currency: string) => { + setFooterCurrencyState({ + searchHash: currentSearchHash, + selectedCurrency: currency, + defaultCurrency: effectiveDefaultCurrency, + queryCurrency: footerSelection.footerCurrency, + }); + applyFooterSelection({footerCurrency: currency}); + }; + + const handleFooterTotalChange = (nextTotalType: SearchFooterTotal) => { + // A hand-picked selection is summed from its own rows, and a to-do search from live Onyx data, so both show the + // new breakdown as soon as the query carries it and wait on nothing. + // Offline nothing can answer it, so the recorded figure stands in and the refetch below asks once back online. + if (!isOffline && !hasPartialSelection && currentSearchQueryJSON && !shouldUseLiveData) { + // Every other case needs the figure from the backend. The breakdown keys no hash — the rows it matches are + // the ones already on screen — so nothing re-runs the search on its own and the footer asks for the + // aggregate itself, against the snapshot the list is already reading. The request carries the page the + // list has reached rather than the first one, so the rows it holds are left where they are. + const nextQueryJSON = buildSearchQueryJSON(getQueryWithFooterSelection(currentSearchQueryJSON, {footerTotal: nextTotalType})); + if (nextQueryJSON) { + setIsAwaitingBreakdownTotal(true); + Promise.resolve( + search({ + queryJSON: nextQueryJSON, + searchKey: currentSearchKey, + offset: metadata?.offset ?? 0, + shouldCalculateTotals: true, + isLoading: false, + shouldShowLoading: false, + }), + ).finally(() => setIsAwaitingBreakdownTotal(false)); + } + } + + // Every case writes the choice into the query: that is what every sum reads, and what saves it against this + // search for the next visit. + applyFooterSelection({footerTotal: nextTotalType}); + }; + + // Asks for the breakdown the snapshot's total does not answer, once nothing the footer sent is still on its way. + useEffect(() => { + if (!isSnapshotTotalForOtherBreakdown || hasPartialSelection || isAwaitingBreakdownTotal || !currentSearchQueryJSON) { + return; + } + search({queryJSON: currentSearchQueryJSON, searchKey: currentSearchKey, offset: metadata?.offset ?? 0, shouldCalculateTotals: true, isLoading: false, shouldShowLoading: false}); + }, [currentSearchKey, currentSearchQueryJSON, hasPartialSelection, isAwaitingBreakdownTotal, isSnapshotTotalForOtherBreakdown, metadata?.offset]); + + const handleFooterCountChange = (nextCountType: SearchFooterCount) => { + applyFooterSelection({footerCount: nextCountType}); + }; const footerData = useMemo(() => { if (!shouldAllowFooterTotals && selectedTransactionsKeys.length === 0) { @@ -529,14 +727,23 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { // 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. + // + // A report's converted total is a single figure with no breakdown inside it, so a breakdown has to be summed + // from the reports' own expenses instead. A Reports search converts by report, so those expenses have no + // converted figures of their own, so the breakdown stays in the default currency and is labelled as such. + const shouldConvertSelectedTotal = shouldUseConvertedSelectedTotal && !(isReportsSearch && footerTotalBreakdown !== CONST.SEARCH.FOOTER_TOTAL.TOTAL); + let total; - if (shouldUseConvertedSelectedTotal && isReportsSearch && selectedCurrency) { + if (shouldConvertSelectedTotal && isReportsSearch && selectedCurrency) { total = selectedReportIDs.reduce((acc, reportID) => acc - (convertedReports?.[reportID]?.[selectedCurrency] ?? 0), 0); } else { total = selectedTransactionsKeys.reduce((acc, key) => { const transaction = selectedTransactions[key]; + if (!doesTransactionMatchFooterTotal(transaction.transaction, footerTotalBreakdown)) { + return acc; + } let convertedAmount; - if (shouldUseConvertedSelectedTotal && selectedCurrency) { + if (shouldConvertSelectedTotal && selectedCurrency) { if (isGroupEntry(key)) { convertedAmount = convertedGroups?.[key]?.[selectedCurrency]; } else if (transaction.transaction?.transactionID) { @@ -547,12 +754,22 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { }, 0); } - return {count: selectedExpenseCount, total, currency: shouldUseConvertedSelectedTotal ? selectedCurrency : fallbackCurrency}; + return {count: selectedExpenseCount, total, currency: shouldConvertSelectedTotal ? selectedCurrency : fallbackCurrency}; } + if (hasExcludedGroupUnderBreakdown) { + return { + count: metadataCount === undefined ? undefined : Math.max(metadataCount - excludedExpenseCount, 0), + total: undefined, + currency: effectiveDefaultCurrency ?? metadataCurrency, + }; + } + + const excludedKeysForTotal = excludedTransactionsKeys.filter((key) => doesTransactionMatchFooterTotal(excludedTransactions[key]?.transaction, footerTotalBreakdown)); if ( hasCustomFooterCurrency && isSearchTotalFresh && + canConvertExcludedReports && ((!hasExcludedExpenses && !hasExcludedReports) || areAllExcludedConverted) && !hasConversionFailed && selectedCurrencyConvertedTotal @@ -561,7 +778,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { if (hasExcludedReports && selectedCurrency) { excludedConvertedTotal = excludedReportIDs.reduce((total, reportID) => total - (convertedReports?.[reportID]?.[selectedCurrency] ?? 0), 0); } else if (hasExcludedExpenses) { - excludedConvertedTotal = excludedTransactionsKeys.reduce((total, key) => { + excludedConvertedTotal = excludedKeysForTotal.reduce((total, key) => { const transaction = excludedTransactions[key]; const transactionID = transaction.transaction?.transactionID; let convertedAmount; @@ -580,7 +797,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { }; } - const excludedTotal = hasExcludedExpenses || hasExcludedReports ? getTransactionTotal(Object.values(excludedTransactions)) : 0; + const excludedTotal = hasExcludedExpenses || hasExcludedReports ? getTransactionTotal(excludedKeysForTotal.map((key) => excludedTransactions[key])) : 0; return { count: metadataCount === undefined ? undefined : Math.max(metadataCount - excludedExpenseCount, 0), total: metadataTotal === undefined ? undefined : metadataTotal - excludedTotal, @@ -589,6 +806,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { }, [ areAllSelectedConverted, areAllExcludedConverted, + canConvertExcludedReports, convertedGroups, convertedReports, convertedTransactions, @@ -600,6 +818,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { hasConversionFailed, hasCustomFooterCurrency, hasExcludedExpenses, + hasExcludedGroupUnderBreakdown, hasExcludedReports, isReportsSearch, isSearchTotalFresh, @@ -614,27 +833,46 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { selectedTransactionsKeys, shouldAllowFooterTotals, shouldUseClientTotal, + footerTotalBreakdown, ]); if (!shouldShowFooter) { return null; } - // 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); - + // A partial selection shows a client-side subtotal that is ready immediately, so it never waits on a search. + const isFooterTotalLoading = + isFooterTotalConverting || (!hasPartialSelection && (isAwaitingBreakdownTotal || isSnapshotTotalForOtherBreakdown || (!!metadata?.isLoading && metadata?.offset === 0))); + + // The reports a selection covers. The server's report count describes the whole search, so a selection needs its own: + // on a Reports search that is the selected reports, elsewhere the distinct reports the selected expenses sit on. + // An unreported expense sits on no report — it carries the unreported placeholder ID — so it adds none. + const selectedReportCount = isReportsSearch + ? selectedReports.length + : new Set(selectedTransactionsKeys.map((key) => selectedTransactions[key]?.reportID).filter((reportID) => !!reportID && reportID !== CONST.REPORT.UNREPORTED_REPORT_ID)).size; + + // The count follows whatever the footer is describing: the selection's own reports when rows were hand-picked, the + // server's report count otherwise. footerData.count already carries the expense side of both cases. + const allMatchingReportCount = metadataReportCount === undefined ? undefined : Math.max(metadataReportCount - excludedReportCount, 0); + const footerReportCount = hasPartialSelection ? selectedReportCount : allMatchingReportCount; + const footerCount = footerCountType === CONST.SEARCH.FOOTER_COUNT.REPORTS ? footerReportCount : footerData.count; return ( ); } export default SearchSelectionFooter; +export {resetAnsweredFooterTotalsForTesting}; diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 1dc2fa805803..b098f3808da8 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -169,6 +169,8 @@ type TaskSearchStatus = ValueOf; type SingularSearchStatus = ExpenseSearchStatus | ExpenseReportSearchStatus | InvoiceSearchStatus | TripSearchStatus | TaskSearchStatus; type SearchGroupBy = ValueOf; type SearchView = ValueOf; +type SearchFooterCount = ValueOf; +type SearchFooterTotal = ValueOf; type SearchCompareMode = ValueOf; // PieChart is not implemented so we exclude it here to prevent TypeScript errors in `SearchChartView.tsx`. type ChartView = Exclude; @@ -543,6 +545,8 @@ export type { TableColumnSize, SearchGroupBy, SearchView, + SearchFooterCount, + SearchFooterTotal, SearchCompareMode, ChartView, SingularSearchStatus, diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index b42684f45d66..600dc355fdda 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -2933,7 +2933,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const selectedMergeReports = areAllMatchingItemsSelected ? [] : selectedReports - .map(({reportID}) => currentSearchResults?.data[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]) + .map(({reportID}) => currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]) .filter((report) => !!report?.reportID && report.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); if ( isBetaEnabled(CONST.BETAS.REPORT_MERGE) && diff --git a/src/hooks/useTodoSearchResults.ts b/src/hooks/useTodoSearchResults.ts index edde7f911be6..289f29b880ce 100644 --- a/src/hooks/useTodoSearchResults.ts +++ b/src/hooks/useTodoSearchResults.ts @@ -1,4 +1,7 @@ +import type {SearchFooterTotal} from '@components/Search/types'; + import type {SearchKey} from '@libs/SearchKeyUtils'; +import {doesTransactionMatchFooterTotal} from '@libs/SearchUIUtils'; import {getTodoReportsForSearchKey} from '@libs/TodosUtils'; import CONST from '@src/CONST'; @@ -24,7 +27,12 @@ type TodoMetadata = { currency: string | undefined; }; -function computeMetadata(reports: Report[], transactionsByReportID: Record): TodoMetadata { +/** + * The count and total the footer shows for a live to-do search. `footerTotal` is the breakdown the query asks for: + * the total covers only the expenses that belong in it, while the count stays every expense the search matched, + * which is how the backend answers a snapshot search. + */ +function computeMetadata(reports: Report[], transactionsByReportID: Record, footerTotal: SearchFooterTotal | undefined): TodoMetadata { let count = 0; let total = 0; let currency: string | undefined; @@ -39,7 +47,7 @@ function computeMetadata(reports: Report[], transactionsByReportID: Record = { leaveWorkspaceReimburser: 'Du kannst diesen Workspace als Erstattungsverantwortliche·r nicht verlassen. Bitte lege unter „Workspaces > Zahlungen senden oder nachverfolgen“ eine·n neue·n Erstattungsverantwortliche·n fest und versuche es erneut.', reimbursable: 'Erstattungsfähig', + nonReimbursable: 'Nicht erstattungsfähig', editYourProfile: 'Profil bearbeiten', comments: 'Kommentare', sharedIn: 'Geteilt in', diff --git a/src/languages/el.ts b/src/languages/el.ts index 83b1fd66bf4d..6f8aec798067 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -449,6 +449,7 @@ const translations: TranslationDeepObject = { leaveWorkspaceReimburser: 'Δεν μπορείτε να αποχωρήσετε από αυτόν τον χώρο εργασίας ως αυτός που πραγματοποιεί τις επιστροφές χρημάτων. Ορίστε νέο υπεύθυνο επιστροφών στο Χώροι εργασίας > Πραγματοποίηση ή παρακολούθηση πληρωμών και δοκιμάστε ξανά.', reimbursable: 'Επανεντάξιμο', + nonReimbursable: 'Μη επιστρέψιμο', editYourProfile: 'Επεξεργαστείτε το προφίλ σας', comments: 'Σχόλια', sharedIn: 'Κοινοποιήθηκε σε', diff --git a/src/languages/en.ts b/src/languages/en.ts index 7504b1f00678..37994d536325 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -534,6 +534,7 @@ const translations = { `If you leave this workspace, you'll be replaced as the technical contact with ${workspaceOwner}, the workspace owner.`, leaveWorkspaceReimburser: "You can't leave this workspace as the reimburser. Please set a new reimburser in Workspaces > Make or track payments, then try again.", reimbursable: 'Reimbursable', + nonReimbursable: 'Non-reimbursable', editYourProfile: 'Edit your profile', comments: 'Comments', sharedIn: 'Shared in', diff --git a/src/languages/es.ts b/src/languages/es.ts index 7b94fe44a402..d9ff46f7e1ac 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -439,6 +439,7 @@ const translations: TranslationDeepObject = { leaveWorkspaceReimburser: 'No puedes salir de este espacio de trabajo como reembolsador. Por favor, establece un nuevo reembolsador en Espacios de trabajo > Realizar o rastrear pagos y luego inténtalo de nuevo.', reimbursable: 'Reembolsable', + nonReimbursable: 'No reembolsable', editYourProfile: 'Edita tu perfil', comments: 'Comentarios', sharedIn: 'Compartido en', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index fd67b6ecedfb..2426a4ed314b 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -449,6 +449,7 @@ const translations: TranslationDeepObject = { leaveWorkspaceReimburser: 'Vous ne pouvez pas quitter cet espace de travail en tant que rembourseur. Définissez un nouveau rembourseur dans Espaces de travail > Effectuer ou suivre les paiements, puis réessayez.', reimbursable: 'Remboursable', + nonReimbursable: 'Non remboursable', editYourProfile: 'Modifier votre profil', comments: 'Commentaires', sharedIn: 'Partagé dans', diff --git a/src/languages/it.ts b/src/languages/it.ts index d94047345316..31db5e8a41a0 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -450,6 +450,7 @@ const translations: TranslationDeepObject = { leaveWorkspaceReimburser: 'Non puoi lasciare questo spazio di lavoro come rimborsatore. Imposta un nuovo rimborsatore in Spazi di lavoro > Effettua o monitora pagamenti, quindi riprova.', reimbursable: 'Rimborsabile', + nonReimbursable: 'Non rimborsabile', editYourProfile: 'Modifica il tuo profilo', comments: 'Commenti', sharedIn: 'Condiviso in', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index d30af5091101..f28a9997c11f 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -449,6 +449,7 @@ const translations: TranslationDeepObject = { leaveWorkspaceReimburser: '支払担当者であるため、このワークスペースを退出することはできません。[ワークスペース]>[支払いの作成または追跡]で新しい支払担当者を設定してから、もう一度お試しください。', reimbursable: '払い戻し対象', + nonReimbursable: '本人精算不可', editYourProfile: 'プロフィールを編集', comments: 'コメント', sharedIn: '共有元', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 6111c200b6b0..8eb51c62e62a 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -449,6 +449,7 @@ const translations: TranslationDeepObject = { leaveWorkspaceReimburser: 'Je kunt deze workspace niet verlaten als terugbetaler. Stel een nieuwe terugbetaler in via Workspaces > Betalingen doen of volgen en probeer het dan opnieuw.', reimbursable: 'Vergoedbaar', + nonReimbursable: 'Niet-vergoedbaar', editYourProfile: 'Bewerk je profiel', comments: 'Opmerkingen', sharedIn: 'Gedeeld in', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 9813a62e7a4b..052f338fe763 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -453,6 +453,7 @@ const translations: TranslationDeepObject = { leaveWorkspaceReimburser: 'Nie możesz opuścić tej przestrzeni roboczej jako osoba rozliczająca wydatki. Ustaw nową osobę rozliczającą w Przestrzenie robocze > Dokonuj lub śledź płatności, a następnie spróbuj ponownie.', reimbursable: 'Podlegające zwrotowi', + nonReimbursable: 'Nierefundowane', editYourProfile: 'Edytuj swój profil', comments: 'Komentarze', sharedIn: 'Udostępnione w', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 13d6f7cbb3c1..a62d9ee25446 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -448,6 +448,7 @@ const translations: TranslationDeepObject = { `Se você sair deste workspace, você será substituído como contato técnico por ${workspaceOwner}, o proprietário do workspace.`, leaveWorkspaceReimburser: 'Você não pode sair deste workspace sendo o reembolsador. Defina um novo reembolsador em Workspaces > Fazer ou acompanhar pagamentos e tente novamente.', reimbursable: 'Reembolsável', + nonReimbursable: 'Não reembolsável', editYourProfile: 'Editar seu perfil', comments: 'Comentários', sharedIn: 'Compartilhado em', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 0a6482392823..b673c49e2a92 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -445,6 +445,7 @@ const translations: TranslationDeepObject = { leaveWorkspaceConfirmationTechContact: (workspaceOwner: string) => `如果你离开此工作区,你将由工作区所有者 ${workspaceOwner} 接任技术联系人。`, leaveWorkspaceReimburser: '作为报销人,您无法离开此工作区。请前往“工作区”>“进行或跟踪付款”中设置新的报销人,然后重试。', reimbursable: '可报销', + nonReimbursable: '不可报销', editYourProfile: '编辑个人资料', comments: '评论', sharedIn: '共享于', diff --git a/src/libs/SearchAutocompleteUtils.ts b/src/libs/SearchAutocompleteUtils.ts index 30934ec63579..29fc562c983a 100644 --- a/src/libs/SearchAutocompleteUtils.ts +++ b/src/libs/SearchAutocompleteUtils.ts @@ -187,6 +187,8 @@ function filterOutRangesWithCorrectValue( const datePresetList = Object.values(CONST.SEARCH.DATE_PRESETS) as string[]; const hasList = Object.values(CONST.SEARCH.HAS_VALUES) as string[]; const isList = Object.values(CONST.SEARCH.IS_VALUES) as string[]; + const footerCountList = Object.values(CONST.SEARCH.FOOTER_COUNT) as string[]; + const footerTotalList = Object.values(CONST.SEARCH.FOOTER_TOTAL) as string[]; if (range.key.startsWith(CONST.SEARCH.REPORT_FIELD.GLOBAL_PREFIX)) { return range.value.length > 0; @@ -212,7 +214,12 @@ function filterOutRangesWithCorrectValue( case CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY: case CONST.SEARCH.SYNTAX_FILTER_KEYS.GROUP_CURRENCY: case CONST.SEARCH.SYNTAX_FILTER_KEYS.PURCHASE_CURRENCY: + case CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_CURRENCY: return currencyList.get().includes(range.value); + case CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_COUNT: + return footerCountList.includes(range.value); + case CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_TOTAL: + return footerTotalList.includes(range.value); case CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE: return typeList.includes(range.value); case CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE: diff --git a/src/libs/SearchParser/autocompleteParser.js b/src/libs/SearchParser/autocompleteParser.js index aff5fc529fc3..a3b269757198 100644 --- a/src/libs/SearchParser/autocompleteParser.js +++ b/src/libs/SearchParser/autocompleteParser.js @@ -310,6 +310,12 @@ function peg$parse(input, options) { var peg$c124 = "\u201C"; var peg$c125 = "\u201D"; var peg$c126 = "\""; + var peg$c127 = "footercount"; + var peg$c128 = "footer-count"; + var peg$c129 = "footercurrency"; + var peg$c130 = "footer-currency"; + var peg$c131 = "footertotal"; + var peg$c132 = "footer-total"; var peg$r0 = /^[ \t\r\n\xA0,:=<>!]/; var peg$r1 = /^[:=]/; @@ -473,6 +479,12 @@ function peg$parse(input, options) { var peg$e144 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); var peg$e145 = peg$classExpectation([","], false, false); var peg$e146 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); + var peg$e147 = peg$literalExpectation("footerCount", true); + var peg$e148 = peg$literalExpectation("footer-count", true); + var peg$e149 = peg$literalExpectation("footerCurrency", true); + var peg$e150 = peg$literalExpectation("footer-currency", true); + var peg$e151 = peg$literalExpectation("footerTotal", true); + var peg$e152 = peg$literalExpectation("footer-total", true); var peg$f0 = function(ranges) { return { autocomplete, ranges }; }; var peg$f1 = function(filters) { return filters.filter(Boolean).flat(); }; @@ -666,6 +678,9 @@ function peg$parse(input, options) { var peg$f120 = function(start, inner, end) { return [...start, '"', ...inner, '"'].join(""); }; + var peg$f121 = function() { return "footerCount"; }; + var peg$f122 = function() { return "footerCurrency"; }; + var peg$f123 = function() { return "footerTotal"; }; var peg$currPos = options.peg$currPos | 0; var peg$savedPos = peg$currPos; var peg$posDetailsCache = [{ line: 1, column: 1 }]; @@ -1191,6 +1206,15 @@ function peg$parse(input, options) { s1 = peg$parsecolumns(); if (s1 === peg$FAILED) { s1 = peg$parselimit(); + if (s1 === peg$FAILED) { + s1 = peg$parsefooterCount(); + if (s1 === peg$FAILED) { + s1 = peg$parsefooterCurrency(); + if (s1 === peg$FAILED) { + s1 = peg$parsefooterTotal(); + } + } + } } } } @@ -5115,12 +5139,99 @@ function peg$parse(input, options) { return s0; } + function peg$parsefooterCount() { + var s0, s1; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 11); + if (s1.toLowerCase() === peg$c127) { + peg$currPos += 11; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e147); } + } + if (s1 === peg$FAILED) { + s1 = input.substr(peg$currPos, 12); + if (s1.toLowerCase() === peg$c128) { + peg$currPos += 12; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e148); } + } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f121(); + } + s0 = s1; + + return s0; + } + + function peg$parsefooterCurrency() { + var s0, s1; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 14); + if (s1.toLowerCase() === peg$c129) { + peg$currPos += 14; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e149); } + } + if (s1 === peg$FAILED) { + s1 = input.substr(peg$currPos, 15); + if (s1.toLowerCase() === peg$c130) { + peg$currPos += 15; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e150); } + } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f122(); + } + s0 = s1; + + return s0; + } + + function peg$parsefooterTotal() { + var s0, s1; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 11); + if (s1.toLowerCase() === peg$c131) { + peg$currPos += 11; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e151); } + } + if (s1 === peg$FAILED) { + s1 = input.substr(peg$currPos, 12); + if (s1.toLowerCase() === peg$c132) { + peg$currPos += 12; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e152); } + } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f123(); + } + s0 = s1; + + return s0; + } + let autocomplete = null; // List fields where you cannot prefix it with "-" to negate it const nonNegatableKeys = new Set([ - "type", "keyword", "groupCurrency", "groupBy", "columns", "limit", "view" + "type", "keyword", "groupCurrency", "groupBy", "columns", "limit", "view", "footerCount", "footerTotal", "footerCurrency" ]); diff --git a/src/libs/SearchParser/autocompleteParser.peggy b/src/libs/SearchParser/autocompleteParser.peggy index 1a24a98020eb..47773b51e148 100644 --- a/src/libs/SearchParser/autocompleteParser.peggy +++ b/src/libs/SearchParser/autocompleteParser.peggy @@ -23,7 +23,7 @@ // List fields where you cannot prefix it with "-" to negate it const nonNegatableKeys = new Set([ - "type", "keyword", "groupCurrency", "groupBy", "columns", "limit", "view" + "type", "keyword", "groupCurrency", "groupBy", "columns", "limit", "view", "footerCount", "footerTotal", "footerCurrency" ]); } @@ -134,6 +134,9 @@ autocompleteKey "key" / reportFieldDynamic / columns / limit + / footerCount + / footerCurrency + / footerTotal ) filterKey diff --git a/src/libs/SearchParser/baseRules.peggy b/src/libs/SearchParser/baseRules.peggy index 5077ae7f8862..a0e24d1e000e 100644 --- a/src/libs/SearchParser/baseRules.peggy +++ b/src/libs/SearchParser/baseRules.peggy @@ -252,3 +252,8 @@ validClosingCondition wordBoundary = [ \t\n\r\xA0,] // whitespace or comma / !. // or end of input + +// Spend footer display selections. Root keys, not filters: they change what the footer shows, never which rows match. +footerCount = ("footerCount"i / "footer-count"i) { return "footerCount"; } +footerCurrency = ("footerCurrency"i / "footer-currency"i) { return "footerCurrency"; } +footerTotal = ("footerTotal"i / "footer-total"i) { return "footerTotal"; } diff --git a/src/libs/SearchParser/searchParser.js b/src/libs/SearchParser/searchParser.js index edaa43a60119..68bf5cb29072 100644 --- a/src/libs/SearchParser/searchParser.js +++ b/src/libs/SearchParser/searchParser.js @@ -311,6 +311,12 @@ function peg$parse(input, options) { var peg$c124 = "\u201C"; var peg$c125 = "\u201D"; var peg$c126 = "\""; + var peg$c127 = "footercount"; + var peg$c128 = "footer-count"; + var peg$c129 = "footercurrency"; + var peg$c130 = "footer-currency"; + var peg$c131 = "footertotal"; + var peg$c132 = "footer-total"; var peg$r0 = /^[^ \t\r\n\xA0]/; var peg$r1 = /^[ \t\r\n\xA0,:=<>!]/; @@ -477,6 +483,12 @@ function peg$parse(input, options) { var peg$e146 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); var peg$e147 = peg$classExpectation([","], false, false); var peg$e148 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); + var peg$e149 = peg$literalExpectation("footerCount", true); + var peg$e150 = peg$literalExpectation("footer-count", true); + var peg$e151 = peg$literalExpectation("footerCurrency", true); + var peg$e152 = peg$literalExpectation("footer-currency", true); + var peg$e153 = peg$literalExpectation("footerTotal", true); + var peg$e154 = peg$literalExpectation("footer-total", true); var peg$f0 = function(filters) { return applyDefaults(filters); }; var peg$f1 = function(head, tail) { @@ -692,6 +704,9 @@ function peg$parse(input, options) { var peg$f120 = function(start, inner, end) { return [...start, '"', ...inner, '"'].join(""); }; + var peg$f121 = function() { return "footerCount"; }; + var peg$f122 = function() { return "footerCurrency"; }; + var peg$f123 = function() { return "footerTotal"; }; var peg$currPos = options.peg$currPos | 0; var peg$savedPos = peg$currPos; var peg$posDetailsCache = [{ line: 1, column: 1 }]; @@ -1283,7 +1298,16 @@ function peg$parse(input, options) { if (s1 === peg$FAILED) { s1 = peg$parsestatus(); if (s1 === peg$FAILED) { - s1 = peg$parsereportFieldDynamic(); + s1 = peg$parsefooterCount(); + if (s1 === peg$FAILED) { + s1 = peg$parsefooterCurrency(); + if (s1 === peg$FAILED) { + s1 = peg$parsefooterTotal(); + if (s1 === peg$FAILED) { + s1 = peg$parsereportFieldDynamic(); + } + } + } } } } @@ -5296,6 +5320,93 @@ function peg$parse(input, options) { return s0; } + function peg$parsefooterCount() { + var s0, s1; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 11); + if (s1.toLowerCase() === peg$c127) { + peg$currPos += 11; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e149); } + } + if (s1 === peg$FAILED) { + s1 = input.substr(peg$currPos, 12); + if (s1.toLowerCase() === peg$c128) { + peg$currPos += 12; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e150); } + } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f121(); + } + s0 = s1; + + return s0; + } + + function peg$parsefooterCurrency() { + var s0, s1; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 14); + if (s1.toLowerCase() === peg$c129) { + peg$currPos += 14; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e151); } + } + if (s1 === peg$FAILED) { + s1 = input.substr(peg$currPos, 15); + if (s1.toLowerCase() === peg$c130) { + peg$currPos += 15; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e152); } + } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f122(); + } + s0 = s1; + + return s0; + } + + function peg$parsefooterTotal() { + var s0, s1; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 11); + if (s1.toLowerCase() === peg$c131) { + peg$currPos += 11; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e153); } + } + if (s1 === peg$FAILED) { + s1 = input.substr(peg$currPos, 12); + if (s1.toLowerCase() === peg$c132) { + peg$currPos += 12; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e154); } + } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f123(); + } + s0 = s1; + + return s0; + } + const GROUP_BY_DEFAULT_SORT = { from: "groupFrom", @@ -5341,7 +5452,7 @@ function peg$parse(input, options) { // List fields where you cannot prefix it with "-" to negate it const nonNegatableKeys = new Set([ - "type", "keyword", "groupCurrency", "groupBy", "columns", "limit", "view", "compare" + "type", "keyword", "groupCurrency", "groupBy", "columns", "limit", "view", "footerCount", "footerTotal", "footerCurrency", "compare" ]); function isDefaultSortValue(sortBy) { diff --git a/src/libs/SearchParser/searchParser.peggy b/src/libs/SearchParser/searchParser.peggy index 09585572ecd6..3a469f4bf432 100644 --- a/src/libs/SearchParser/searchParser.peggy +++ b/src/libs/SearchParser/searchParser.peggy @@ -72,7 +72,7 @@ // List fields where you cannot prefix it with "-" to negate it const nonNegatableKeys = new Set([ - "type", "keyword", "groupCurrency", "groupBy", "columns", "limit", "view", "compare" + "type", "keyword", "groupCurrency", "groupBy", "columns", "limit", "view", "footerCount", "footerTotal", "footerCurrency", "compare" ]); function isDefaultSortValue(sortBy) { @@ -311,6 +311,9 @@ key "key" / purchaseAmount / policyID / status + / footerCount + / footerCurrency + / footerTotal / reportFieldDynamic ) diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 2b431e455f64..d19de92dfd7a 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -15,6 +15,8 @@ import type { SearchDateKey, SearchDatePreset, SearchFilterKey, + SearchFooterCount, + SearchFooterTotal, SearchQueryJSON, SearchQueryString, SearchWithdrawalType, @@ -112,6 +114,22 @@ const VALID_IS_TYPES = new Set(Object.values(CONST.SEARCH.IS_VALUES)); const VALID_WITHDRAWAL_TYPES = new Set(Object.values(CONST.SEARCH.WITHDRAWAL_TYPE)); const VALID_WITHDRAWAL_STATUSES = new Set(Object.values(CONST.SEARCH.SETTLEMENT_STATUS)); const VALID_PAID_STATUSES = new Set(Object.values(CONST.SEARCH.PAID_STATUS)); +const VALID_FOOTER_COUNTS = new Set(Object.values(CONST.SEARCH.FOOTER_COUNT)); +const VALID_FOOTER_TOTALS = new Set(Object.values(CONST.SEARCH.FOOTER_TOTAL)); +const FOOTER_FILTER_KEYS = new Set([ + CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_COUNT, + CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_TOTAL, + CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_CURRENCY, +]); + +function isFooterCount(value: string): value is SearchFooterCount { + return VALID_FOOTER_COUNTS.has(value); +} + +function isFooterTotal(value: string): value is SearchFooterTotal { + return VALID_FOOTER_TOTALS.has(value); +} + const VALID_GROUP_BYS = new Set(Object.values(CONST.SEARCH.GROUP_BY)); // Create reverse lookup maps for O(1) performance const createKeyToUserFriendlyMap = () => { @@ -671,13 +689,74 @@ function getFilters(queryJSON: SearchQueryJSON) { } function getFilterFromQuery(queryJSON: SearchQueryJSON | undefined, filterKey: SearchAdvancedFiltersKey): Filter { - const filters = queryJSON?.flatFilters.find((filter) => filter.key === filterKey)?.filters; + const filters = queryJSON?.flatFilters?.find((filter) => filter.key === filterKey)?.filters; const isNegated = filters?.at(0)?.operator === CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO; const value = filters?.map((filter) => filter.value.toString()); return {value, isNegated}; } +/** + * The Spend footer's display selections, read off the query the footer is showing. A value the footer cannot render is + * dropped rather than displayed, so a hand-typed one behaves like no selection at all. The currency is upper-cased so + * `footerCurrency:eur` and `footerCurrency:EUR` are one selection rather than two. + */ +function getFooterSelectionFromQuery(queryJSON: SearchQueryJSON | undefined) { + const count = getFilterFromQuery(queryJSON, CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_COUNT).value?.at(0); + const total = getFilterFromQuery(queryJSON, CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_TOTAL).value?.at(0); + const currency = getFilterFromQuery(queryJSON, CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_CURRENCY).value?.at(0); + + return { + footerCount: count && isFooterCount(count) ? count : undefined, + footerTotal: total && isFooterTotal(total) ? total : undefined, + footerCurrency: currency ? currency.toUpperCase() : undefined, + }; +} + +function getQueryWithoutFooterSelections(queryJSON: SearchQueryJSON | Readonly) { + return buildSearchQueryString({...queryJSON, flatFilters: queryJSON.flatFilters.filter((filter) => !FOOTER_FILTER_KEYS.has(filter.key))}); +} + +function hasFooterSelections(queryJSON: SearchQueryJSON | Readonly) { + return queryJSON.flatFilters.some((filter) => FOOTER_FILTER_KEYS.has(filter.key)); +} + +/** + * The query the Spend footer's selections produce, built from the query the footer is showing. Only `footerTotal` + * changes what the backend returns. The other two ride along so the selection is restored with the search. + */ +/** The count a footer with no selection shows: reports on the Reports search, expenses everywhere else. */ +function getDefaultFooterCount(type: SearchDataTypes | undefined): SearchFooterCount { + return type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT ? CONST.SEARCH.FOOTER_COUNT.REPORTS : CONST.SEARCH.FOOTER_COUNT.EXPENSES; +} + +function getQueryWithFooterSelection( + queryJSON: SearchQueryJSON | Readonly, + selection: {footerCount?: SearchFooterCount; footerTotal?: SearchFooterTotal; footerCurrency?: string}, +) { + const flatFilters = queryJSON.flatFilters.filter((filter) => !FOOTER_FILTER_KEYS.has(filter.key)); + const nextSelection = {...getFooterSelectionFromQuery(queryJSON), ...selection}; + + for (const [key, value] of [ + [CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_COUNT, nextSelection.footerCount], + [CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_TOTAL, nextSelection.footerTotal], + [CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_CURRENCY, nextSelection.footerCurrency], + ] as const) { + // The plain total and the type's own count are what a query with no selection already shows, so they are left + // out rather than spelled out: going back to them returns the query to what it was before the footer was + // touched, in the URL and in a saved search alike. + const isDefaultSelection = + (key === CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_TOTAL && value === CONST.SEARCH.FOOTER_TOTAL.TOTAL) || + (key === CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_COUNT && value === getDefaultFooterCount(queryJSON.type)); + if (!value || isDefaultSelection) { + continue; + } + flatFilters.push({key, filters: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value}]}); + } + + return buildSearchQueryString({...queryJSON, flatFilters}); +} + /** * Whether the query includes a positive `has:submitted-violation` or `has:approved-violation` filter. * Used so the Violations column and CSV export only appear when those filters are active. Normal @@ -886,6 +965,12 @@ function getQueryHashes(query: SearchQueryJSON) { continue; } + // The Spend footer's selections ride along as filters but match no rows, so they must not make a query look + // like a different (or a less similar) search, or key a snapshot of its own. + if (FOOTER_FILTER_KEYS.has(filterKey)) { + continue; + } + if (!similarSearchIgnoredFilters.has(filterKey)) { filterSet.add(filterKey); } @@ -911,6 +996,7 @@ function getQueryHashes(query: SearchQueryJSON) { if (query.limit !== undefined) { orderedQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT}:${query.limit}`; } + if (query.compare) { orderedQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.COMPARE}:${query.compare}`; } @@ -1084,7 +1170,16 @@ function buildSearchQueryString(queryJSON?: SearchQueryJSON | Readonly([CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD, CONST.SEARCH.SYNTAX_FILTER_KEYS.GROUP_CURRENCY]); +// Filters that are not row filters, so they neither light up the Reset button nor get cleared by it. The Spend footer's +// selections belong here for the same reason the group currency does: they change what the footer displays, not which +// rows the search matches. +const NON_FILTER_CHIP_KEYS = new Set([ + CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD, + CONST.SEARCH.SYNTAX_FILTER_KEYS.GROUP_CURRENCY, + CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_COUNT, + CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_TOTAL, + CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_CURRENCY, +]); const NON_SAVABLE_FILTER_KEYS = new Set([CONST.SEARCH.SYNTAX_FILTER_KEYS.GROUP_CURRENCY]); function buildQueryStringWithResetFilters(currentQueryJSON: SearchQueryJSON, defaultQueryJSON: SearchQueryJSON | undefined) { @@ -1210,7 +1305,7 @@ function buildQueryStringFromFilterFormValues(filterValues: Partial { const isNegated = filterKey.endsWith(CONST.SEARCH.NOT_MODIFIER); @@ -1995,6 +2104,19 @@ function buildFilterFormValuesFromQuery( filtersForm[FILTER_KEYS.LIMIT] = queryJSON.limit.toString(); } + const footerSelection = getFooterSelectionFromQuery(queryJSON); + if (footerSelection.footerCount) { + filtersForm[FILTER_KEYS.FOOTER_COUNT] = footerSelection.footerCount; + } + + if (footerSelection.footerTotal) { + filtersForm[FILTER_KEYS.FOOTER_TOTAL] = footerSelection.footerTotal; + } + + if (footerSelection.footerCurrency) { + filtersForm[FILTER_KEYS.FOOTER_CURRENCY] = footerSelection.footerCurrency; + } + if (queryJSON.compare) { filtersForm[FILTER_KEYS.COMPARE] = queryJSON.compare; } @@ -2479,12 +2601,16 @@ function buildCannedSearchQuery({ return buildSearchQueryString(normalizedQueryJSON); } +function hasNonFooterFilters(queryJSON: SearchQueryJSON | Readonly) { + return !!queryJSON.filters && queryJSON.flatFilters.some((filter) => !FOOTER_FILTER_KEYS.has(filter.key)); +} + function isDefaultExpensesQuery(queryJSON: SearchQueryJSON | Readonly) { - return queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE && !queryJSON.filters && !queryJSON.groupBy; + return queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE && !hasNonFooterFilters(queryJSON) && !queryJSON.groupBy; } function isDefaultExpenseReportsQuery(queryJSON: SearchQueryJSON | Readonly) { - return queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && !queryJSON.filters && !queryJSON.groupBy; + return queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && !hasNonFooterFilters(queryJSON) && !queryJSON.groupBy; } /** @@ -2985,6 +3111,11 @@ export { getRangeQueryValue, getQueryHashWithoutFilters, getQueryHashes, + getDefaultFooterCount, + getFooterSelectionFromQuery, + getQueryWithFooterSelection, + getQueryWithoutFooterSelections, + hasFooterSelections, hasFiltersChangedFromDefault, isSearchQuerySavable, withExactMatchFilterKeys, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index a2bec325a21a..5483dc5c2e11 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -41,6 +41,7 @@ import type { SearchDateFilterKeys, SearchDatePreset, SearchFilterKey, + SearchFooterTotal, SearchGroupBy, SearchPaidStatus, SearchQueryJSON, @@ -226,6 +227,7 @@ import { getMCCForDisplay, getOriginalAmountForDisplay, getReceiptTypeTranslationKey, + getReimbursable, getReportOwnerAccountIDAsAttendee, getReportOwnerAsAttendee, getTag, @@ -658,6 +660,9 @@ const SKIPPED_SEARCH_FILTERS = new Set([ FILTER_KEYS.ACTION, FILTER_KEYS.COLUMNS, FILTER_KEYS.KEYWORD, + FILTER_KEYS.FOOTER_COUNT, + FILTER_KEYS.FOOTER_TOTAL, + FILTER_KEYS.FOOTER_CURRENCY, FILTER_KEYS.EXPORTER, ]); @@ -4715,6 +4720,38 @@ function isTransactionSearchType(type: string | undefined): boolean { return type === CONST.SEARCH.DATA_TYPES.EXPENSE || type === CONST.SEARCH.DATA_TYPES.INVOICE; } +/** + * The breakdown a snapshot's total answers. A settled snapshot with no stamp predates it, when only the plain total was + * asked for. One with neither is a response still landing, whose stamp follows, so it is unknown. + */ +function getSnapshotFooterTotal(search: OnyxEntry): SearchFooterTotal | undefined { + if (search?.footerTotal) { + return search.footerTotal; + } + return search?.state === CONST.SEARCH.SNAPSHOT_STATE.LOADED ? CONST.SEARCH.FOOTER_TOTAL.TOTAL : undefined; +} + +/** + * Whether an expense belongs in the total the Spend footer is showing. Reimbursable is the product default, so only + * an explicit `false` makes an expense non-reimbursable — read through `getReimbursable`, the same helper the row's + * own Reimbursable column renders. Billable works the other way round. Every consumer of a footer breakdown goes + * through this, so a snapshot total, a live to-do total and a selection subtotal classify a row identically. + */ +function doesTransactionMatchFooterTotal(transaction: OnyxEntry, totalType: SearchFooterTotal | undefined): boolean { + switch (totalType) { + case CONST.SEARCH.FOOTER_TOTAL.REIMBURSABLE: + return !!transaction && getReimbursable(transaction); + case CONST.SEARCH.FOOTER_TOTAL.NON_REIMBURSABLE: + return !!transaction && !getReimbursable(transaction); + case CONST.SEARCH.FOOTER_TOTAL.BILLABLE: + return !!transaction && transaction.billable === true; + case CONST.SEARCH.FOOTER_TOTAL.NON_BILLABLE: + return !!transaction && transaction.billable !== true; + default: + return true; + } +} + function isTodoSearch(recentSearchHash: number, suggestedSearches: Record) { const matchedSearchKey = Object.values(suggestedSearches).find((search) => search.recentSearchHash === recentSearchHash)?.key; return !!matchedSearchKey && TODO_SEARCH_KEYS.has(matchedSearchKey); @@ -5362,6 +5399,22 @@ function getViewOptions(translate: LocalizedTranslate) { return Object.values(CONST.SEARCH.VIEW).map>((value) => ({text: translate(`search.view.${value}`), value})); } +/** + * The options the Spend footer's total selector offers, in display order. `Total spend` is the default and always + * applies. The four aggregates are computed by the backend, so picking one re-runs the search. + */ +function getFooterTotalItems(translate: LocalizedTranslate) { + const labels: Record = { + [CONST.SEARCH.FOOTER_TOTAL.TOTAL]: translate('common.spend'), + [CONST.SEARCH.FOOTER_TOTAL.REIMBURSABLE]: translate('common.reimbursable'), + [CONST.SEARCH.FOOTER_TOTAL.NON_REIMBURSABLE]: translate('common.nonReimbursable'), + [CONST.SEARCH.FOOTER_TOTAL.BILLABLE]: translate('common.billable'), + [CONST.SEARCH.FOOTER_TOTAL.NON_BILLABLE]: translate('common.nonBillable'), + }; + + return Object.values(CONST.SEARCH.FOOTER_TOTAL).map>((value) => ({text: labels[value], value})); +} + function getCurrencyOptions(currencyList: OnyxTypes.CurrencyList, getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']) { return Object.keys(currencyList).reduce( (options, currencyCode) => { @@ -7325,6 +7378,8 @@ function isTransactionMatchWithGroupItem(transaction: OnyxTypes.Transaction, gro } export { + doesTransactionMatchFooterTotal, + getSnapshotFooterTotal, getSearchBulkEditPolicyID, getSuggestedSearches, getSections, @@ -7381,6 +7436,7 @@ export { getSortOrderOptions, getGroupBySections, getViewOptions, + getFooterTotalItems, getCurrencyOptions, getFeedOptions, getWideAmountIndicators, diff --git a/src/libs/actions/IOU/SearchUpdate.ts b/src/libs/actions/IOU/SearchUpdate.ts index c4dbf8c3d5cd..02cd1c9f9df6 100644 --- a/src/libs/actions/IOU/SearchUpdate.ts +++ b/src/libs/actions/IOU/SearchUpdate.ts @@ -1,4 +1,4 @@ -import type {SearchQueryJSON} from '@components/Search/types'; +import type {SearchFilterKey, SearchQueryJSON} from '@components/Search/types'; import {getTransactionDisplayAmount, isExpenseReport, isOptimisticPersonalDetail} from '@libs/ReportUtils'; import {buildCannedSearchQuery, buildSearchQueryJSON, buildSearchQueryString, getCurrentSearchQueryJSON, getFilterFromQuery} from '@libs/SearchQueryUtils'; @@ -95,13 +95,20 @@ function shouldOptimisticallyUpdateSearch( (isInvoice && currentSearchQueryJSON.type === CONST.SEARCH.DATA_TYPES.INVOICE) || (iouReport?.type === CONST.REPORT.TYPE.EXPENSE && currentSearchQueryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT); + // The Spend footer's selections restrict nothing — they pick which figures the footer prints — so the query is judged + // as if they were not there, and a new expense still lands on the list, which matters most offline. + const FOOTER_FILTER_KEYS = new Set([ + CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_COUNT, + CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_TOTAL, + CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_CURRENCY, + ]); + const flatFilters = currentSearchQueryJSON.flatFilters.filter((filter) => !FOOTER_FILTER_KEYS.has(filter.key)); + // `status` and `policyID` are regular filters now, but they used to be root keys. They are already accounted for by the // status/policyID checks above, so they don't count as restrictive flat filters when deciding whether to optimistically update. - const hasNoFlatFilters = currentSearchQueryJSON.flatFilters.every( - (filter) => filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS || filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID, - ); + const hasNoFlatFilters = flatFilters.every((filter) => filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS || filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID); - const onlyFromFilter = currentSearchQueryJSON.flatFilters.length === 1 ? currentSearchQueryJSON.flatFilters.at(0) : undefined; + const onlyFromFilter = flatFilters.length === 1 ? flatFilters.at(0) : undefined; const matchesFromQuery: boolean = onlyFromFilter?.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM && onlyFromFilter.filters.some((f) => f.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO && String(f.value) === String(currentUserAccountID)); diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index f6202ee82b2d..0be518e7d649 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -5,7 +5,7 @@ import type {PersonalDetailsByLogin} from '@components/PersonalDetailsByLoginPro import type {PopoverMenuItem} from '@components/PopoverMenu'; import type {HoldMenuCallback} from '@components/Search'; import type {TransactionListItemType, TransactionReportGroupListItemType} from '@components/Search/SearchList/ListItem/types'; -import type {BankAccountMenuItem, BulkPaySelectionData, PaymentData, SearchQueryJSON, SelectedReports, SelectedTransactions} from '@components/Search/types'; +import type {BankAccountMenuItem, BulkPaySelectionData, PaymentData, SearchFooterTotal, SearchQueryJSON, SelectedReports, SelectedTransactions} from '@components/Search/types'; import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import type {ReportSubmitToPopoverOpenOptions} from '@hooks/useReportSubmitToPopover'; @@ -66,7 +66,7 @@ import { } from '@libs/ReportUtils'; import type {SearchKey} from '@libs/SearchKeyUtils'; import {savedSearchIDToSearchKey} from '@libs/SearchKeyUtils'; -import {buildSearchQueryJSON, buildSearchQueryString, serializeQueryJSONForBackend} from '@libs/SearchQueryUtils'; +import {buildSearchQueryJSON, buildSearchQueryString, getFooterSelectionFromQuery, serializeQueryJSONForBackend} from '@libs/SearchQueryUtils'; import {isTransactionGroupListItemType} from '@libs/SearchUIUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import {cancelSpan, endSpan, startSpan} from '@libs/telemetry/activeSpans'; @@ -1192,6 +1192,10 @@ function openBulkChangeApproverPage(reportIDList: OpenBulkChangeApproverPagePara type InFlightSearchRequest = { shouldCalculateTotals: boolean; shouldSaveRecentSearch: boolean; + /** The footer breakdown it asks the total for. Not part of the hash, so it is told apart here. */ + footerTotal?: SearchFooterTotal; + /** The request, including any re-fire chained onto it. */ + promise?: Promise; pendingShouldCalculateTotals?: boolean; pendingShouldSaveRecentSearch?: boolean; pendingUpgradeRequest?: () => Promise | undefined; @@ -1311,6 +1315,7 @@ function search({ } const dedupeKey = `${queryJSON.hash}_${offset ?? 0}`; + const {footerTotal} = getFooterSelectionFromQuery(queryJSON); const inFlightRequest = inFlightSearchRequests.get(dedupeKey); if (inFlightRequest) { // Not just EXPENSE: any type can now need totals (e.g. EXPENSE_REPORT's reportCount). Gating this @@ -1320,7 +1325,10 @@ function search({ // A user-submitted query colliding with an unflagged in-flight request (e.g. a programmatic refresh // of the same query) must still reach the backend flagged, or it never enters recent searches. const needsSaveRecentSearchUpgrade = shouldSaveRecentSearch && !inFlightRequest.shouldSaveRecentSearch; - if (needsTotalsUpgrade || needsSaveRecentSearchUpgrade) { + // Another footer breakdown shares the hash but not the total, so it re-fires after the in-flight one, and the + // latest breakdown wins without racing it. Its caller gets the chain, so it waits until that total lands. + const needsBreakdownRefire = footerTotal !== inFlightRequest.footerTotal; + if (needsTotalsUpgrade || needsSaveRecentSearchUpgrade || needsBreakdownRefire) { // Accumulate desired flags so a later upgrade for one dimension can't drop an earlier // upgrade for the other. Only a single pending re-fire is kept. inFlightRequest.pendingShouldCalculateTotals = (inFlightRequest.pendingShouldCalculateTotals ?? inFlightRequest.shouldCalculateTotals) || shouldCalculateTotals; @@ -1341,9 +1349,9 @@ function search({ shouldShowLoading: !inFlightRequest.didSucceed || inFlightRequest.pendingShouldCalculateTotals !== inFlightRequest.shouldCalculateTotals, }); } - return; + return needsBreakdownRefire ? inFlightRequest.promise : undefined; } - const inFlightRequestState: InFlightSearchRequest = {shouldCalculateTotals, shouldSaveRecentSearch}; + const inFlightRequestState: InFlightSearchRequest = {shouldCalculateTotals, shouldSaveRecentSearch, footerTotal}; inFlightSearchRequests.set(dedupeKey, inFlightRequestState); const onyxLoadingData = getOnyxLoadingData(queryJSON.hash, queryJSON, offset, true, shouldCalculateTotals, shouldShowLoading); @@ -1372,6 +1380,12 @@ function search({ const optimisticData: Array> = [...(onyxLoadingData.optimisticData ?? [])]; const failureData: Array> = [...(onyxLoadingData.failureData ?? [])]; const finallyData = onyxLoadingData.finallyData; + // The breakdown the total now answers, so the footer can tell a total for another one apart. A later page without + // totals keeps the earlier total, so it leaves the stamp alone. + const successData: Array> = + shouldCalculateTotals || !offset + ? [{onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${queryJSON.hash}`, value: {search: {footerTotal: footerTotal ?? CONST.SEARCH.FOOTER_TOTAL.TOTAL}}}] + : []; if (searchKey) { optimisticData.push({ @@ -1386,7 +1400,7 @@ function search({ } const startRequest = () => - makeRequestWithSideEffects(READ_COMMANDS.SEARCH, {hash: queryJSON.hash, jsonQuery}, {optimisticData, finallyData, failureData}) + makeRequestWithSideEffects(READ_COMMANDS.SEARCH, {hash: queryJSON.hash, jsonQuery}, {optimisticData, successData, finallyData, failureData}) .then((result) => { const response = result?.onyxData?.[0]?.value as OnyxSearchResponse; inFlightRequestState.didSucceed = result?.jsonCode === CONST.JSON_CODE.SUCCESS; @@ -1469,7 +1483,8 @@ function search({ }; if (skipWaitForWrites) { - return startRequest().catch(handleSearchError); + inFlightRequestState.promise = startRequest().catch(handleSearchError); + return inFlightRequestState.promise; } // A search can sit behind the whole write queue here. That wait happens before the request is dispatched, so none of the @@ -1486,7 +1501,7 @@ function search({ }, }); - return waitForWrites(READ_COMMANDS.SEARCH) + inFlightRequestState.promise = waitForWrites(READ_COMMANDS.SEARCH) .then(() => { endSpan(queueSpanId); return startRequest(); @@ -1496,6 +1511,7 @@ function search({ cancelSpan(queueSpanId); return handleSearchError(error); }); + return inFlightRequestState.promise; } /** diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index e888e550a96d..f9fcd25fa461 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -141,7 +141,8 @@ function SearchPage({route}: SearchPageProps) { // Only a filter refinement is worth holding for. A sidebar item or saved search is a different search, so its // results area starts from the skeleton rather than showing rows that belong to the query the user just left. - const shouldHoldLastResolvedSearch = !isSearchResolvedForCurrentQuery && !!lastResolvedSearch && !hasStaleHoldTimedOut && isQueryARefinement(currentSearchQueryJSON?.inputQuery); + const shouldHoldLastResolvedSearch = + !shouldUseLiveData && !isSearchResolvedForCurrentQuery && !!lastResolvedSearch && !hasStaleHoldTimedOut && isQueryARefinement(currentSearchQueryJSON?.inputQuery); const contentQueryJSON = shouldHoldLastResolvedSearch ? lastResolvedSearch.queryJSON : currentSearchQueryJSON; const contentSearchResults = shouldHoldLastResolvedSearch ? lastResolvedSearch.searchResults : searchResults; diff --git a/src/pages/home/YourSpendSection/useYourSpendData.ts b/src/pages/home/YourSpendSection/useYourSpendData.ts index 8aa681848b6c..91fead4efa12 100644 --- a/src/pages/home/YourSpendSection/useYourSpendData.ts +++ b/src/pages/home/YourSpendSection/useYourSpendData.ts @@ -9,7 +9,7 @@ import {WRITE_COMMANDS} from '@libs/API/types'; import {getDisplayableExpensifyCards, getDisplayableThirdPartyCards, isPersonalCard, lastFourNumbersFromCardName} from '@libs/CardUtils'; import {arePaymentsEnabled, isPaidGroupPolicy} from '@libs/PolicyUtils'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; -import {isGroupEntry} from '@libs/SearchUIUtils'; +import {getSnapshotFooterTotal, isGroupEntry} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -358,6 +358,12 @@ function getCardTotalsByCardID(snapshot: OnyxEntry): Record): boolean { + return !!searchResults && getSnapshotFooterTotal(searchResults.search) !== CONST.SEARCH.FOOTER_TOTAL.TOTAL; +} + function getYourSpendRowState({isApplicable, isOffline, searchResults}: GetYourSpendRowStateParams): YourSpendRowState { if (!isApplicable) { return YOUR_SPEND_ROW_STATE.HIDDEN; @@ -374,6 +380,17 @@ function getYourSpendRowState({isApplicable, isOffline, searchResults}: GetYourS return YOUR_SPEND_ROW_STATE.READY; } +function getLiveRowState(rawState: YourSpendRowState, shouldUseCached: boolean, isUnconfirmed: boolean, isOffline: boolean): YourSpendRowState { + if (shouldUseCached) { + return YOUR_SPEND_ROW_STATE.READY; + } + if (!isUnconfirmed || rawState !== YOUR_SPEND_ROW_STATE.READY) { + return rawState; + } + // Online, Home's own search confirms a total soon. Offline nothing will, so the row hides as with no snapshot. + return isOffline ? YOUR_SPEND_ROW_STATE.HIDDEN_EMPTY : YOUR_SPEND_ROW_STATE.LOADING; +} + function useYourSpendData(): UseYourSpendDataReturn { const {accountID} = useCurrentUserPersonalDetails(); const {isOffline} = useNetwork(); @@ -502,14 +519,19 @@ function useYourSpendData(): UseYourSpendDataReturn { setCachedApprovalHash(approvalHash); } + const isApprovalTotalUnconfirmed = isUnconfirmedTotal(approvalSearchResults); + const isPaymentTotalUnconfirmed = isUnconfirmedTotal(paymentSearchResults); + if ( approvalRowStateRaw === YOUR_SPEND_ROW_STATE.READY && + !isApprovalTotalUnconfirmed && (!cachedApprovalReady || cachedApprovalReady.total !== approvalTotalsRaw.total || cachedApprovalReady.currency !== approvalTotalsRaw.currency) ) { setCachedApprovalReady({total: approvalTotalsRaw.total, currency: approvalTotalsRaw.currency}); } if ( paymentRowStateRaw === YOUR_SPEND_ROW_STATE.READY && + !isPaymentTotalUnconfirmed && (!cachedPaymentReady || cachedPaymentReady.total !== paymentTotalsRaw.total || cachedPaymentReady.currency !== paymentTotalsRaw.currency) ) { setCachedPaymentReady({total: paymentTotalsRaw.total, currency: paymentTotalsRaw.currency}); @@ -524,17 +546,20 @@ function useYourSpendData(): UseYourSpendDataReturn { // OUTSTANDING report. An empty signature means nothing is awaiting approval, so the row // must hide immediately after approving the last expense. A zero-result search returns // no count, which would otherwise keep the stale cached total on screen. + // An unconfirmed total is bridged the same way, and waits on Home's own refetch when nothing is cached. const shouldUseCachedApproval = - approvalRowStateRaw === YOUR_SPEND_ROW_STATE.HIDDEN_EMPTY && - approvalCountIsMissing && + ((approvalRowStateRaw === YOUR_SPEND_ROW_STATE.HIDDEN_EMPTY && approvalCountIsMissing) || isApprovalTotalUnconfirmed) && approvalSearchResults !== undefined && cachedApprovalReady !== null && cachedApprovalHash === approvalHash && outstandingReportsSignature !== ''; - const shouldUseCachedPayment = paymentRowStateRaw === YOUR_SPEND_ROW_STATE.HIDDEN_EMPTY && paymentCountIsMissing && paymentSearchResults !== undefined && cachedPaymentReady !== null; + const shouldUseCachedPayment = + ((paymentRowStateRaw === YOUR_SPEND_ROW_STATE.HIDDEN_EMPTY && paymentCountIsMissing) || isPaymentTotalUnconfirmed) && + paymentSearchResults !== undefined && + cachedPaymentReady !== null; - const approvalRowStateLive = shouldUseCachedApproval ? YOUR_SPEND_ROW_STATE.READY : approvalRowStateRaw; - const paymentRowStateLive = shouldUseCachedPayment ? YOUR_SPEND_ROW_STATE.READY : paymentRowStateRaw; + const approvalRowStateLive = getLiveRowState(approvalRowStateRaw, shouldUseCachedApproval, isApprovalTotalUnconfirmed, isOffline); + const paymentRowStateLive = getLiveRowState(paymentRowStateRaw, shouldUseCachedPayment, isPaymentTotalUnconfirmed, isOffline); const approvalTotalsLive: YourSpendRowTotals = shouldUseCachedApproval && cachedApprovalReady ? cachedApprovalReady : approvalTotalsRaw; const paymentTotalsLive: YourSpendRowTotals = shouldUseCachedPayment && cachedPaymentReady ? cachedPaymentReady : paymentTotalsRaw; diff --git a/src/types/form/SearchAdvancedFiltersForm.ts b/src/types/form/SearchAdvancedFiltersForm.ts index f0b17df5675a..f860ec0faaaf 100644 --- a/src/types/form/SearchAdvancedFiltersForm.ts +++ b/src/types/form/SearchAdvancedFiltersForm.ts @@ -6,6 +6,8 @@ import type { SearchAmountFilterKeys, SearchCustomColumnIds, SearchDateFilterKeys, + SearchFooterCount, + SearchFooterTotal, SearchGroupBy, SearchTextFilterKeys, SearchPaidStatus, @@ -264,6 +266,10 @@ const FILTER_KEYS = { COLUMNS: 'columns', LIMIT: 'limit', + + FOOTER_COUNT: 'footerCount', + FOOTER_TOTAL: 'footerTotal', + FOOTER_CURRENCY: 'footerCurrency', COMPARE: 'compare', } as const; @@ -392,6 +398,9 @@ const ALLOWED_TYPE_FILTERS: Record> = { FILTER_KEYS.ATTENDEE_NOT, FILTER_KEYS.COLUMNS, FILTER_KEYS.LIMIT, + FILTER_KEYS.FOOTER_COUNT, + FILTER_KEYS.FOOTER_TOTAL, + FILTER_KEYS.FOOTER_CURRENCY, ]), [CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT]: new Set([ FILTER_KEYS.TYPE, @@ -478,6 +487,9 @@ const ALLOWED_TYPE_FILTERS: Record> = { FILTER_KEYS.ORDER_DEAL_NUMBERS_NOT, FILTER_KEYS.REPORT_FIELD, FILTER_KEYS.COLUMNS, + FILTER_KEYS.FOOTER_COUNT, + FILTER_KEYS.FOOTER_TOTAL, + FILTER_KEYS.FOOTER_CURRENCY, ]), [CONST.SEARCH.DATA_TYPES.INVOICE]: new Set([ FILTER_KEYS.TYPE, @@ -919,6 +931,10 @@ type SearchAdvancedFiltersForm = Form< [FILTER_KEYS.ATTENDEE_NOT]: string[]; [FILTER_KEYS.REPORT_FIELD]: string; [FILTER_KEYS.LIMIT]: string; + + [FILTER_KEYS.FOOTER_COUNT]: SearchFooterCount; + [FILTER_KEYS.FOOTER_TOTAL]: SearchFooterTotal; + [FILTER_KEYS.FOOTER_CURRENCY]: string; [FILTER_KEYS.COMPARE]: string; } & Record & Record & diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index 4c575e59b846..c01d07fba869 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -5,7 +5,7 @@ import type { TransactionListItemType, TransactionReportGroupListItemType, } from '@components/Search/SearchList/ListItem/types'; -import type {SearchGroupBy, SearchSortBy, SortOrder} from '@components/Search/types'; +import type {SearchFooterTotal, SearchGroupBy, SearchSortBy, SortOrder} from '@components/Search/types'; import type CONST from '@src/CONST'; import type ONYXKEYS from '@src/ONYXKEYS'; @@ -51,6 +51,9 @@ type SearchResultsInfo = { /** Client-only query string this snapshot was searched with, used to add optimistic expenses to matching snapshots */ inputQuery?: string; + /** Client-only footer breakdown the snapshot's `total` answers. Written once a response lands, as it can replace `snapshot.search` */ + footerTotal?: SearchFooterTotal; + /** Whether the user can fetch more search results */ hasMoreResults: boolean; diff --git a/tests/actions/IOU/SearchUpdateTest.ts b/tests/actions/IOU/SearchUpdateTest.ts index 8286de8688e1..0d54a03f1b7c 100644 --- a/tests/actions/IOU/SearchUpdateTest.ts +++ b/tests/actions/IOU/SearchUpdateTest.ts @@ -12,7 +12,7 @@ import {hasMissingSmartscanFields} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; -import {buildCannedSearchQuery, getCurrentSearchQueryJSON} from '@src/libs/SearchQueryUtils'; +import {buildCannedSearchQuery, buildSearchQueryJSON, getCurrentSearchQueryJSON} from '@src/libs/SearchQueryUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Report, Transaction} from '@src/types/onyx'; import type {SearchResultDataType} from '@src/types/onyx/SearchResults'; @@ -123,6 +123,53 @@ describe('actions/IOU', () => { }); describe('shouldOptimisticallyUpdateSearch', () => { + it('puts a new expense on "My expenses" even when the query carries a Spend footer selection', () => { + const transaction = {...createRandomTransaction(1)}; + const iouReport: Report = { + ...createRandomReport(2, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + // Given "My expenses", whose only filter is the current user + const myExpenses = buildSearchQueryJSON(`type:expense from:${RORY_ACCOUNT_ID}`); + expect(myExpenses && shouldOptimisticallyUpdateSearch(myExpenses, iouReport, false, RORY_ACCOUNT_ID, transaction)).toBeTruthy(); + + // Then a footer selection alongside it changes nothing: it picks which figures the footer prints, it does + // not narrow the rows, and offline the server's answer never comes to correct the list + const withFooterCurrency = buildSearchQueryJSON(`type:expense from:${RORY_ACCOUNT_ID} footerCurrency:EUR`); + expect(withFooterCurrency && shouldOptimisticallyUpdateSearch(withFooterCurrency, iouReport, false, RORY_ACCOUNT_ID, transaction)).toBeTruthy(); + + const withEveryFooterSelection = buildSearchQueryJSON(`type:expense from:${RORY_ACCOUNT_ID} footerCount:reports footerTotal:billable footerCurrency:EUR`); + expect(withEveryFooterSelection && shouldOptimisticallyUpdateSearch(withEveryFooterSelection, iouReport, false, RORY_ACCOUNT_ID, transaction)).toBeTruthy(); + + // While a filter that does narrow the rows still keeps the expense off the list + const someoneElsesExpenses = buildSearchQueryJSON(`type:expense from:${RORY_ACCOUNT_ID + 1} footerCurrency:EUR`); + expect(someoneElsesExpenses && shouldOptimisticallyUpdateSearch(someoneElsesExpenses, iouReport, false, RORY_ACCOUNT_ID, transaction)).toBeFalsy(); + + const narrowedByCategory = buildSearchQueryJSON(`type:expense from:${RORY_ACCOUNT_ID} category:Travel footerCurrency:EUR`); + expect(narrowedByCategory && shouldOptimisticallyUpdateSearch(narrowedByCategory, iouReport, false, RORY_ACCOUNT_ID, transaction)).toBeFalsy(); + }); + + it('leaves a "from" search with a status filter as main had it, with or without a Spend footer selection', () => { + const transaction = {...createRandomTransaction(1)}; + const iouReport: Report = { + ...createRandomReport(2, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + // Given the current user's drafts, which pairs the "from" filter with a status + const myDrafts = buildSearchQueryJSON(`type:expense from:${RORY_ACCOUNT_ID} status:drafts`); + const myDraftsWithFooterSelection = buildSearchQueryJSON(`type:expense from:${RORY_ACCOUNT_ID} status:drafts footerCurrency:EUR`); + + // Then neither gets a new expense inserted: the footer keys are ignored, and nothing else is widened + expect(myDrafts && shouldOptimisticallyUpdateSearch(myDrafts, iouReport, false, RORY_ACCOUNT_ID, transaction)).toBeFalsy(); + expect(myDraftsWithFooterSelection && shouldOptimisticallyUpdateSearch(myDraftsWithFooterSelection, iouReport, false, RORY_ACCOUNT_ID, transaction)).toBeFalsy(); + }); + it('when the current hash is submit action query it should only return true if the iou report is in draft state', () => { const transaction = { ...createRandomTransaction(1), diff --git a/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts b/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts index fa60cd35a21f..a5e3d40be5d2 100644 --- a/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts +++ b/tests/unit/HomePage/YourSpendSection/useYourSpendDataTest.ts @@ -174,6 +174,8 @@ function makeSearchResultsWithCount(count: number): SearchResults { hasResults: count > 0, isLoading: false, count, + // Every Search response is stamped with the breakdown its total answers once it lands. + footerTotal: CONST.SEARCH.FOOTER_TOTAL.TOTAL, }, data: {}, }; @@ -1030,6 +1032,175 @@ describe('useYourSpendData — drops the approval cache when no outstanding repo expect(result.current.approvalRowState).toBe(YOUR_SPEND_ROW_STATE.READY); }); + + it('keeps the plain approval total when the Search footer answers a breakdown on the same snapshot', () => { + // Given Home showing the plain awaiting-approval total + setupReports([makeReport()]); + const plainSnapshot = makeSearchResultsWithCount(1); + plainSnapshot.search.total = 900; + setupApprovalSnapshot(plainSnapshot); + const {result, rerender} = renderHook(() => useYourSpendData()); + expect(result.current.approvalTotals.total).toBe(900); + + // When the Search footer, opened from that row, answers non-reimbursable on the snapshot they share + const breakdownSnapshot = makeSearchResultsWithCount(1); + breakdownSnapshot.search.total = 0; + breakdownSnapshot.search.footerTotal = CONST.SEARCH.FOOTER_TOTAL.NON_REIMBURSABLE; + setupApprovalSnapshot(breakdownSnapshot); + rerender(undefined); + + // Then Home keeps its own total, which is also the one it replays once offline + expect(result.current.approvalRowState).toBe(YOUR_SPEND_ROW_STATE.READY); + expect(result.current.approvalTotals.total).toBe(900); + mockedUseNetwork.mockReturnValue(networkState(true)); + rerender(undefined); + expect(result.current.approvalTotals.total).toBe(900); + }); + + it('waits for its own total rather than showing a breakdown one it has nothing cached for', () => { + // Given Home first seeing the snapshot after the Search footer answered non-reimbursable on it + setupReports([makeReport()]); + const breakdownSnapshot = makeSearchResultsWithCount(1); + breakdownSnapshot.search.total = 0; + breakdownSnapshot.search.footerTotal = CONST.SEARCH.FOOTER_TOTAL.NON_REIMBURSABLE; + setupApprovalSnapshot(breakdownSnapshot); + + const {result} = renderHook(() => useYourSpendData()); + + // Then the row loads until Home's own search answers the plain total + expect(result.current.approvalRowState).toBe(YOUR_SPEND_ROW_STATE.LOADING); + }); + + it('keeps the plain approval total through a response that lands before its stamp', () => { + // Given Home showing the plain awaiting-approval total + setupReports([makeReport()]); + const plainSnapshot = makeSearchResultsWithCount(1); + plainSnapshot.search.total = 900; + setupApprovalSnapshot(plainSnapshot); + const {result, rerender} = renderHook(() => useYourSpendData()); + expect(result.current.approvalTotals.total).toBe(900); + + // When a breakdown response replaces the snapshot's search info, wiping the stamp until its success data lands + const unstampedSnapshot = makeSearchResultsWithCount(1); + unstampedSnapshot.search.total = 0; + unstampedSnapshot.search.footerTotal = undefined; + setupApprovalSnapshot(unstampedSnapshot); + rerender(undefined); + + // Then Home neither shows nor caches that figure, so the stamp arriving later leaves it on its own total + expect(result.current.approvalTotals.total).toBe(900); + const breakdownSnapshot = makeSearchResultsWithCount(1); + breakdownSnapshot.search.total = 0; + breakdownSnapshot.search.footerTotal = CONST.SEARCH.FOOTER_TOTAL.NON_REIMBURSABLE; + setupApprovalSnapshot(breakdownSnapshot); + rerender(undefined); + expect(result.current.approvalTotals.total).toBe(900); + mockedUseNetwork.mockReturnValue(networkState(true)); + rerender(undefined); + expect(result.current.approvalTotals.total).toBe(900); + }); + + it('takes a new plain total once it is stamped as one', () => { + // Given Home showing the plain awaiting-approval total + setupReports([makeReport()]); + const plainSnapshot = makeSearchResultsWithCount(1); + plainSnapshot.search.total = 900; + setupApprovalSnapshot(plainSnapshot); + const {result, rerender} = renderHook(() => useYourSpendData()); + + // When Home's own search answers a new plain total + const nextPlainSnapshot = makeSearchResultsWithCount(2); + nextPlainSnapshot.search.total = 1500; + setupApprovalSnapshot(nextPlainSnapshot); + rerender(undefined); + + // Then the row shows it + expect(result.current.approvalTotals.total).toBe(1500); + }); + + it('hides the row offline rather than loading forever when no plain total is confirmed', () => { + // Given Home first seeing, offline, a snapshot whose total answers a breakdown + mockedUseNetwork.mockReturnValue(networkState(true)); + setupReports([makeReport()]); + const breakdownSnapshot = makeSearchResultsWithCount(1); + breakdownSnapshot.search.total = 0; + breakdownSnapshot.search.footerTotal = CONST.SEARCH.FOOTER_TOTAL.NON_REIMBURSABLE; + setupApprovalSnapshot(breakdownSnapshot); + + const {result} = renderHook(() => useYourSpendData()); + + // Then the row hides, as with no snapshot at all, since nothing can confirm a total until back online + expect(result.current.approvalRowState).toBe(YOUR_SPEND_ROW_STATE.HIDDEN_EMPTY); + }); + + describe('a snapshot cached before the stamp existed', () => { + function makeSettledUnstampedSnapshot(total: number): SearchResults { + const snapshot = makeSearchResultsWithCount(1); + snapshot.search.total = total; + snapshot.search.footerTotal = undefined; + snapshot.search.state = CONST.SEARCH.SNAPSHOT_STATE.LOADED; + return snapshot; + } + + it('shows its total online, since only the plain total was asked for before the stamp', () => { + // Given an upgrade onto a snapshot that finished loading before the stamp existed + setupReports([makeReport()]); + setupApprovalSnapshot(makeSettledUnstampedSnapshot(900)); + + // When Home renders online, before its own search returns + const {result} = renderHook(() => useYourSpendData()); + + // Then the cached total shows straight away, as before the upgrade + expect(result.current.approvalRowState).toBe(YOUR_SPEND_ROW_STATE.READY); + expect(result.current.approvalTotals.total).toBe(900); + }); + + it('shows its total offline instead of hiding the row', () => { + // Given the same upgrade, opened offline with nothing cached in memory + mockedUseNetwork.mockReturnValue(networkState(true)); + setupReports([makeReport()]); + setupApprovalSnapshot(makeSettledUnstampedSnapshot(900)); + + const {result} = renderHook(() => useYourSpendData()); + + // Then the row keeps the cached total + expect(result.current.approvalRowState).toBe(YOUR_SPEND_ROW_STATE.READY); + expect(result.current.approvalTotals.total).toBe(900); + }); + }); + + describe('an unstamped snapshot whose search is still loading', () => { + function makeLoadingUnstampedSnapshot(total: number): SearchResults { + const snapshot = makeSearchResultsWithCount(1); + snapshot.search.total = total; + snapshot.search.footerTotal = undefined; + snapshot.search.state = CONST.SEARCH.SNAPSHOT_STATE.LOADING; + return snapshot; + } + + it('does not take its total online, since the breakdown it answers is not known yet', () => { + // Given a snapshot with no stamp whose search has not settled, so its total could be any breakdown + setupReports([makeReport()]); + setupApprovalSnapshot(makeLoadingUnstampedSnapshot(0)); + + const {result} = renderHook(() => useYourSpendData()); + + // Then the row loads until Home's own search confirms a plain total + expect(result.current.approvalRowState).toBe(YOUR_SPEND_ROW_STATE.LOADING); + }); + + it('hides the row offline with nothing cached', () => { + // Given the same snapshot, opened offline with nothing cached in memory + mockedUseNetwork.mockReturnValue(networkState(true)); + setupReports([makeReport()]); + setupApprovalSnapshot(makeLoadingUnstampedSnapshot(0)); + + const {result} = renderHook(() => useYourSpendData()); + + // Then the row hides rather than show a total that may be another breakdown's + expect(result.current.approvalRowState).toBe(YOUR_SPEND_ROW_STATE.HIDDEN_EMPTY); + }); + }); }); // isApprovalStale / isPaymentStale — grey only the total a queued change would move diff --git a/tests/unit/Search/FooterTotalSelectionRetentionTest.tsx b/tests/unit/Search/FooterTotalSelectionRetentionTest.tsx new file mode 100644 index 000000000000..735dac11a309 --- /dev/null +++ b/tests/unit/Search/FooterTotalSelectionRetentionTest.tsx @@ -0,0 +1,153 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import {useSearchRowSelectionActions, useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; +import {SearchContextProvider} from '@components/Search/SearchContextProvider'; +import type {TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; +import SearchWriteActionsProvider from '@components/Search/SearchWriteActionsProvider'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {SearchResults} from '@src/types/onyx'; + +import type * as ReactNavigation from '@react-navigation/native'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import {buildTransactionRow} from '../../utils/collections/searchListItems'; +import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: () => true, + useRoute: jest.fn(() => ({key: 'search-test-route'})), + useRootNavigationState: jest.fn(() => undefined), + useNavigation: jest.fn(() => ({ + getState: jest.fn(() => undefined), + addListener: jest.fn(() => jest.fn()), + navigate: jest.fn(), + })), +})); + +/** The search the rows were selected in. */ +const SEARCH_HASH = 1; + +/** A filter change, which lands on a different search altogether. */ +const OTHER_SEARCH_HASH = 3; + +const buildRow = (index: number) => buildTransactionRow(index, `${index}`, {currency: 'USD', amount: -500}); + +/** The first page the search answers with. */ +const firstPageRowA = buildRow(1); +const firstPageRowB = buildRow(2); + +/** Rows that only arrive once the user scrolls and the next page loads in. */ +const laterPageRowA = buildRow(3); +const laterPageRowB = buildRow(4); + +const firstPage = [firstPageRowA, firstPageRowB]; +const bothPages = [...firstPage, laterPageRowA, laterPageRowB]; + +function makeSearchResults(hash: number, rows: TransactionListItemType[]): SearchResults { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- minimal fixture: only the fields the selection reconciliation reads are required + return { + data: Object.fromEntries(rows.map((row) => [`${ONYXKEYS.COLLECTION.TRANSACTION}${row.transactionID}`, row])), + search: { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + hash, + sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + offset: 0, + hasMoreResults: true, + hasResults: true, + isLoading: false, + count: bothPages.length, + total: 200000, + currency: 'USD', + }, + } as unknown as SearchResults; +} + +/** What the list is rendering. Swapped between renders to stand for a page load or a re-run of the search. */ +let renderedRows: TransactionListItemType[] = bothPages; +let renderedHash = SEARCH_HASH; + +function Wrapper({children}: {children: React.ReactNode}) { + return ( + + + {children} + + + ); +} + +const renderSelection = () => + renderHook( + () => ({ + ...useSearchSelectionContext(), + ...useSearchSelectionActions(), + ...useSearchRowSelectionActions(), + }), + {wrapper: Wrapper}, + ); + +describe('Selection as the search data changes', () => { + beforeAll(() => Onyx.init({keys: ONYXKEYS})); + + beforeEach(() => { + renderedRows = bothPages; + renderedHash = SEARCH_HASH; + }); + + afterEach(async () => { + await Onyx.clear(); + }); + + it('still drops a row that disappears without the search changing', async () => { + // Given a selected row the current search did show + const {result, rerender} = renderSelection(); + await act(async () => { + result.current.toggle(laterPageRowB); + await waitForBatchedUpdatesWithAct(); + }); + expect(Object.keys(result.current.selectedTransactions)).toEqual(['4']); + + // When it leaves the data on the same hash, which is what a delete looks like + renderedRows = firstPage; + rerender({}); + await waitForBatchedUpdatesWithAct(); + + // Then it is pruned, as before + expect(result.current.selectedTransactions).toEqual({}); + }); + + it('still drops unloaded rows when the query change is a real one', async () => { + const {result, rerender} = renderSelection(); + await act(async () => { + result.current.toggle(laterPageRowB); + await waitForBatchedUpdatesWithAct(); + }); + + // When a filter change moves the search onto another hash + renderedRows = firstPage; + renderedHash = OTHER_SEARCH_HASH; + rerender({}); + await waitForBatchedUpdatesWithAct(); + + // Then the selection is not carried into it + expect(result.current.selectedTransactions).toEqual({}); + }); +}); diff --git a/tests/unit/Search/SearchPageFooterTest.tsx b/tests/unit/Search/SearchPageFooterTest.tsx new file mode 100644 index 000000000000..d71c9485f41e --- /dev/null +++ b/tests/unit/Search/SearchPageFooterTest.tsx @@ -0,0 +1,315 @@ +import {fireEvent, render, screen, within} from '@testing-library/react-native'; + +import SearchPageFooter from '@components/Search/SearchPageFooter'; +import type {SearchFooterCount, SearchFooterTotal} from '@components/Search/types'; + +import CONST from '@src/CONST'; + +import type ReactNative from 'react-native'; + +import React from 'react'; + +type CapturedPopupProps = { + countType?: SearchFooterCount; + defaultCountType?: SearchFooterCount; + totalType?: SearchFooterTotal; + isTotalLoading?: boolean; + currency?: string; +}; + +const mockCapturedPopupProps: {current: CapturedPopupProps | undefined} = {current: undefined}; +const mockCapturedSkeletonProps: {current: {height?: number; shouldStandInForWholeRow?: boolean} | undefined} = {current: undefined}; + +type MockFilterPopupButtonProps = { + PopoverComponent: (props: {closeOverlay: () => void; isExpanded: boolean}) => React.ReactNode; + renderButton: (props: {onPress: () => void; ref: React.RefObject; isExpanded: boolean}) => React.ReactNode; +}; + +// Any style/theme key resolves, so the real Text and View render without a theme provider. +jest.mock('@hooks/useThemeStyles', () => ({__esModule: true, default: () => new Proxy({}, {get: () => ({})})})); +jest.mock('@hooks/useStyleUtils', () => ({__esModule: true, default: () => new Proxy({}, {get: () => () => ({})})})); +jest.mock('@hooks/useTheme', () => ({__esModule: true, default: () => new Proxy({}, {get: () => 'transparent'})})); +const mockIsOffline = {current: false}; +jest.mock('@hooks/useNetwork', () => ({__esModule: true, default: () => ({isOffline: mockIsOffline.current})})); +jest.mock('@hooks/useResponsiveLayout', () => ({__esModule: true, default: () => ({shouldUseNarrowLayout: false, isSmallScreenWidth: false})})); +jest.mock('@hooks/useKeyboardShortcut', () => ({__esModule: true, default: () => {}})); +const mockIsFooterSelectorsBetaEnabled = {current: true}; +jest.mock('@hooks/usePermissions', () => ({ + __esModule: true, + default: () => ({ + isBetaEnabled: (beta: string) => (beta === 'spendFooterSelectors' ? mockIsFooterSelectorsBetaEnabled.current : false), + isBetaEnabledOrUnknown: () => false, + }), +})); +jest.mock('@hooks/useLazyAsset', () => ({useMemoizedLazyExpensifyIcons: () => new Proxy({}, {get: () => undefined})})); +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({translate: (key: string) => key}), +})); +jest.mock('@hooks/useCurrencyList', () => ({ + useCurrencyListActions: () => ({convertToDisplayString: (amount: number | undefined, currency: string | undefined) => `${currency ?? ''}${amount ?? 0}`}), +})); +// The whole footer content is the button, so the tests reach the count and the total through it. Only the icon, which +// has nothing to assert on, renders as nothing. +jest.mock('@components/Button', () => { + const ReactModule = require('react'); + const {View: MockView, Text: MockText} = require('react-native'); + + function MockButton({children, isDisabled}: {children?: React.ReactNode; isDisabled?: boolean}) { + return ReactModule.createElement(MockView, {testID: 'searchPageFooterTrigger', accessibilityState: {disabled: !!isDisabled}}, children); + } + function MockButtonText({children}: {children?: React.ReactNode}) { + return ReactModule.createElement(MockText, null, children); + } + function MockButtonIcon() { + return null; + } + MockButton.Text = MockButtonText; + MockButton.Icon = MockButtonIcon; + return {__esModule: true, default: MockButton}; +}); +jest.mock('@components/Search/SearchPageFooterSkeleton', () => ({ + __esModule: true, + default: (props: {height?: number; shouldStandInForWholeRow?: boolean}) => { + mockCapturedSkeletonProps.current = props; + return null; + }, +})); +const mockCapturedCurrencyPopupProps: {current: {value?: string; defaultValue?: string} | undefined} = {current: undefined}; +jest.mock('@components/Search/FilterDropdowns/CurrencyPopup', () => ({ + __esModule: true, + default: (props: {value?: string; defaultValue?: string}) => { + mockCapturedCurrencyPopupProps.current = props; + return null; + }, +})); +jest.mock('@components/Search/FilterDropdowns/FilterPopupButton', () => { + // Render both halves so the trigger and the popover's props can both be reached. + function MockFilterPopupButton({PopoverComponent, renderButton}: MockFilterPopupButtonProps) { + return ( + <> + {renderButton({onPress: () => {}, ref: {current: null}, isExpanded: false})} + {PopoverComponent({closeOverlay: () => {}, isExpanded: false})} + + ); + } + return {__esModule: true, default: MockFilterPopupButton}; +}); +jest.mock('@components/Search/FilterDropdowns/SearchFooterPopup', () => ({ + __esModule: true, + default: (props: CapturedPopupProps) => { + mockCapturedPopupProps.current = props; + return null; + }, +})); + +const defaultProps = { + count: 1204, + countType: CONST.SEARCH.FOOTER_COUNT.EXPENSES, + defaultCountType: CONST.SEARCH.FOOTER_COUNT.EXPENSES, + totalType: CONST.SEARCH.FOOTER_TOTAL.TOTAL, + shouldShowTotalSelector: true, + onTotalChange: () => {}, + total: -192000, + currency: CONST.CURRENCY.USD, + defaultCurrency: CONST.CURRENCY.USD, + isTotalLoading: false, + onCurrencyChange: () => {}, + onCountChange: () => {}, +}; + +describe('SearchPageFooter', () => { + beforeEach(() => { + mockIsFooterSelectorsBetaEnabled.current = true; + mockIsOffline.current = false; + mockCapturedPopupProps.current = undefined; + mockCapturedSkeletonProps.current = undefined; + mockCapturedCurrencyPopupProps.current = undefined; + }); + + it('labels the count with the unit the footer is displaying', () => { + render(); + + expect(screen.getByText('common.expenses:')).toBeOnTheScreen(); + expect(screen.getByText('1204')).toBeOnTheScreen(); + }); + + it('labels the count as reports when the footer is displaying the report count', () => { + render( + , + ); + + expect(screen.getByText('common.reports:')).toBeOnTheScreen(); + expect(screen.getByText('87')).toBeOnTheScreen(); + }); + + it('falls back to the expenses label when no count selection applies', () => { + render( + , + ); + + expect(screen.getByText('common.expenses:')).toBeOnTheScreen(); + // The popup hides its count row on an undefined selection, so the footer offers currency only. + expect(mockCapturedPopupProps.current?.countType).toBeUndefined(); + }); + + it('labels the total with the aggregate the footer is displaying', () => { + render( + , + ); + + expect(screen.getByText('common.reimbursable:')).toBeOnTheScreen(); + expect(screen.queryByText('common.totalSpend:')).not.toBeOnTheScreen(); + }); + + it('labels the default total as total spend, including when no total selection applies', () => { + render( + , + ); + + expect(screen.getByText('common.totalSpend:')).toBeOnTheScreen(); + }); + + it('keeps the count and both labels while the total reloads', () => { + render( + , + ); + + // Only the total's value is replaced by the skeleton: the count and the labels hold their place. + expect(screen.getByText('common.expenses:')).toBeOnTheScreen(); + expect(screen.getByText('1204')).toBeOnTheScreen(); + expect(screen.getByText('common.totalSpend:')).toBeOnTheScreen(); + // The menu stays reachable while the total reloads: it is the total's own row that goes inert, not the trigger. + expect(mockCapturedPopupProps.current?.isTotalLoading).toBe(true); + }); + + it('keeps the display menu reachable offline, since switching the count needs no request', () => { + // Given the device is offline + mockIsOffline.current = true; + + // When the footer renders + render(); + + // Then its trigger still opens the menu, where only the rows that need the backend wait + expect(screen.getByTestId('searchPageFooterTrigger')).not.toBeDisabled(); + }); + + it('makes the whole footer content the trigger, so the count opens the menu too', () => { + render(); + + const trigger = within(screen.getByTestId('searchPageFooterTrigger')); + expect(trigger.getByText('common.expenses:')).toBeOnTheScreen(); + expect(trigger.getByText('1204')).toBeOnTheScreen(); + expect(trigger.getByText('common.totalSpend:')).toBeOnTheScreen(); + expect(trigger.getByText('USD-192000')).toBeOnTheScreen(); + }); + + it('stands the skeleton in at the height the real total was measured at, so the row does not grow', () => { + const {rerender} = render(); + + // The layout pass the real total goes through is what reports the height the skeleton has to match. + fireEvent(screen.getByTestId('searchPageFooterTotal'), 'layout', {nativeEvent: {layout: {width: 87, height: 16, x: 0, y: 0}}}); + + rerender( + , + ); + + expect(mockCapturedSkeletonProps.current?.height).toBe(16); + }); + + it('hands the display menu the current and default count selections', () => { + render( + , + ); + + expect(mockCapturedPopupProps.current).toEqual( + expect.objectContaining({ + countType: CONST.SEARCH.FOOTER_COUNT.EXPENSES, + defaultCountType: CONST.SEARCH.FOOTER_COUNT.REPORTS, + currency: CONST.CURRENCY.USD, + }), + ); + }); + + describe('with the beta off', () => { + beforeEach(() => { + mockIsFooterSelectorsBetaEnabled.current = false; + }); + + it('hangs the currency picker off the total itself, with no display menu in between', () => { + render(); + + // The menu is never rendered, and the popover the chevron opens is the currency list + expect(mockCapturedPopupProps.current).toBeUndefined(); + expect(mockCapturedCurrencyPopupProps.current).toEqual(expect.objectContaining({value: CONST.CURRENCY.USD, defaultValue: CONST.CURRENCY.USD})); + }); + + it('labels the count and the total the way they read without the selectors', () => { + render(); + + expect(screen.getByText('common.expenses:')).toBeOnTheScreen(); + expect(screen.getByText('common.totalSpend:')).toBeOnTheScreen(); + expect(screen.getByText('1204')).toBeOnTheScreen(); + }); + + it('ignores a count selection it cannot offer', () => { + render( + , + ); + + // Reports would be the label with the beta on; without it the footer only ever counts expenses + expect(screen.getByText('common.expenses:')).toBeOnTheScreen(); + expect(screen.queryByText('common.reports:')).not.toBeOnTheScreen(); + }); + + it('stands the skeleton in for the whole row while the total reloads', () => { + render( + , + ); + + expect(mockCapturedSkeletonProps.current).toEqual(expect.objectContaining({shouldStandInForWholeRow: true})); + }); + + it('renders nothing for the total when there is none, so no chevron is left behind', () => { + render( + , + ); + + expect(screen.queryByText('common.totalSpend:')).not.toBeOnTheScreen(); + expect(mockCapturedCurrencyPopupProps.current).toBeUndefined(); + }); + }); +}); diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index 4dc18a32f76c..7c054525c2d4 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -24,6 +24,10 @@ import { getFilterDisplayValue, getFilterFormValues, getFilterFromQuery, + getFooterSelectionFromQuery, + getQueryWithFooterSelection, + getQueryWithoutFooterSelections, + hasFooterSelections, queryHasViolationFilter, hasValuesIncludeViolationFilter, getDateFilterRange, @@ -466,6 +470,15 @@ describe('SearchQueryUtils', () => { expect(result).toEqual(`${defaultQuery} amount:2000000 foo test`); }); + test('keeps the Spend footer selections when a typed query is rebuilt', () => { + const result = getQueryWithUpdatedValues('type:expense footer-count:reports footer-total:reimbursable footer-currency:eur'); + + expect(result).toContain('footerCount:reports'); + expect(result).toContain('footerTotal:reimbursable'); + // The currency is read back upper-cased. The query keeps whatever case was typed, which nothing depends on. + expect(result).toContain('footerCurrency:eur'); + }); + test('rebuilds a single value containing a comma as one value', () => { expect(getQueryWithUpdatedValues(String.raw`merchant:Globex\,Ltd`)).toEqual(`${defaultQuery} merchant:"Globex,Ltd"`); expect(getQueryWithUpdatedValues('merchant:"Globex,Ltd"')).toEqual(`${defaultQuery} merchant:"Globex,Ltd"`); @@ -1049,6 +1062,45 @@ describe('SearchQueryUtils', () => { }); }); + describe('Spend footer selections', () => { + test('includes every footer selection in the query string', () => { + const filterValues: Partial = { + type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, + footerCount: CONST.SEARCH.FOOTER_COUNT.REPORTS, + footerTotal: CONST.SEARCH.FOOTER_TOTAL.NON_REIMBURSABLE, + footerCurrency: 'EUR', + }; + + const result = buildQueryStringFromFilterFormValues(filterValues); + + expect(result).toEqual('type:expense-report footerCount:reports footerTotal:non-reimbursable footerCurrency:EUR'); + }); + + test('omits footer selections that are not set', () => { + const filterValues: Partial = { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + footerTotal: CONST.SEARCH.FOOTER_TOTAL.BILLABLE, + }; + + const result = buildQueryStringFromFilterFormValues(filterValues); + + expect(result).toEqual('type:expense footerTotal:billable'); + }); + + test('drops footer selections for a type that has no Spend footer', () => { + const filterValues: Partial = { + type: CONST.SEARCH.DATA_TYPES.TRIP, + footerCount: CONST.SEARCH.FOOTER_COUNT.EXPENSES, + footerTotal: CONST.SEARCH.FOOTER_TOTAL.REIMBURSABLE, + footerCurrency: 'EUR', + }; + + const result = buildQueryStringFromFilterFormValues(filterValues); + + expect(result).toEqual('type:trip'); + }); + }); + describe('compare option', () => { test('includes valid compare mode in query string when provided in form values', () => { // Given form values carrying a valid compare mode @@ -1375,6 +1427,33 @@ describe('SearchQueryUtils', () => { expect(merchantFilter?.value).toBe('Lyft'); }); + test('renders the Spend footer selections in their user-friendly spelling, so an edited query keeps them', () => { + const queryJSON = buildSearchQueryJSON('type:expense footerCount:reports footerTotal:non-reimbursable footerCurrency:EUR'); + + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + + const result = buildUserReadableQueryString({ + queryJSON, + PersonalDetails: undefined, + reports: emptyReports, + taxRates: emptyTaxRates, + cardList: emptyCardList, + cardFeeds: emptyCardFeeds, + policies: emptyPolicies, + currentUserAccountID, + autoCompleteWithSpace: false, + translate: translateLocal, + formatPhoneNumber, + reportAttributes: undefined, + }); + + expect(result).toContain('footer-count:reports'); + expect(result).toContain('footer-total:non-reimbursable'); + expect(result).toContain('footer-currency:EUR'); + }); + test('includes limit in readable query string when present', () => { const queryJSON = buildSearchQueryJSON('type:expense limit:25'); @@ -1530,6 +1609,22 @@ describe('SearchQueryUtils', () => { }); }); + test('reads the Spend footer selections back into the filter form', () => { + const queryJSON = buildSearchQueryJSON('type:expense-report footerCount:reports footerTotal:reimbursable footerCurrency:EUR'); + + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + + const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}); + + expect(result).toMatchObject({ + footerCount: CONST.SEARCH.FOOTER_COUNT.REPORTS, + footerTotal: CONST.SEARCH.FOOTER_TOTAL.REIMBURSABLE, + footerCurrency: 'EUR', + }); + }); + test('conversion amount filters parse from either spelling of the key', () => { const queryJSON = buildSearchQueryJSON('type:expense-report amount-debited>1000 amountReimbursed<2000'); @@ -2487,6 +2582,163 @@ describe('SearchQueryUtils', () => { expect(resetQuery).not.toContain('tea'); }); }); + describe('Spend footer selection hashing', () => { + it('gives the footer count selection no effect on any hash, so switching it needs no new search', () => { + const expenses = buildSearchQueryJSON('type:expense-report footerCount:expenses'); + const reports = buildSearchQueryJSON('type:expense-report footerCount:reports'); + const noSelection = buildSearchQueryJSON('type:expense-report'); + + expect(getFooterSelectionFromQuery(expenses).footerCount).toEqual(CONST.SEARCH.FOOTER_COUNT.EXPENSES); + expect(getFooterSelectionFromQuery(reports).footerCount).toEqual(CONST.SEARCH.FOOTER_COUNT.REPORTS); + expect(expenses?.hash).toEqual(reports?.hash); + expect(expenses?.hash).toEqual(noSelection?.hash); + expect(expenses?.recentSearchHash).toEqual(noSelection?.recentSearchHash); + expect(expenses?.similarSearchHash).toEqual(noSelection?.similarSearchHash); + }); + + it('leaves the plain total out of the query, so switching back to it returns the query to what it was', () => { + const queryJSON = buildSearchQueryJSON('type:expense footerTotal:billable footerCurrency:EUR'); + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + + const backToPlainTotal = getQueryWithFooterSelection(queryJSON, {footerTotal: CONST.SEARCH.FOOTER_TOTAL.TOTAL}); + + expect(backToPlainTotal).not.toContain('footerTotal'); + // The currency rides along untouched. + expect(backToPlainTotal).toContain('footerCurrency:EUR'); + }); + + it("leaves the type's own count out of the query, so resetting it returns the query to what it was", () => { + // Given an Expenses and a Reports search each switched to the other count + const expensesQueryJSON = buildSearchQueryJSON('type:expense footerCount:reports'); + const reportsQueryJSON = buildSearchQueryJSON('type:expense-report footerCount:expenses'); + if (!expensesQueryJSON || !reportsQueryJSON) { + throw new Error('Failed to parse query string'); + } + + // When each is reset to the count its type shows by default + const expensesReset = getQueryWithFooterSelection(expensesQueryJSON, {footerCount: CONST.SEARCH.FOOTER_COUNT.EXPENSES}); + const reportsReset = getQueryWithFooterSelection(reportsQueryJSON, {footerCount: CONST.SEARCH.FOOTER_COUNT.REPORTS}); + + // Then neither spells the default out + expect(expensesReset).not.toContain('footerCount'); + expect(reportsReset).not.toContain('footerCount'); + }); + + it('still writes the count that is not the type default', () => { + // Given an Expenses search with no count selection + const queryJSON = buildSearchQueryJSON('type:expense'); + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + + // When the footer switches it to reports + const result = getQueryWithFooterSelection(queryJSON, {footerCount: CONST.SEARCH.FOOTER_COUNT.REPORTS}); + + // Then the choice is kept in the query + expect(result).toContain('footerCount:reports'); + }); + + it('leaves every hash alone for the footer total, so one search keeps one snapshot', () => { + const noSelection = buildSearchQueryJSON('type:expense'); + const reimbursable = buildSearchQueryJSON('type:expense footerTotal:reimbursable'); + const billable = buildSearchQueryJSON('type:expense footerTotal:billable'); + + // The breakdown is a different aggregate over the same rows, so it keys nothing: the list keeps its + // snapshot, its pages and its selection across a switch, and the footer refreshes the figures itself. + expect(reimbursable?.hash).toEqual(noSelection?.hash); + expect(billable?.hash).toEqual(noSelection?.hash); + expect(reimbursable?.recentSearchHash).toEqual(noSelection?.recentSearchHash); + expect(reimbursable?.similarSearchHash).toEqual(noSelection?.similarSearchHash); + }); + + it('leaves every hash alone for the footer currency, which Search ignores and a separate command converts', () => { + const noSelection = buildSearchQueryJSON('type:expense'); + const usd = buildSearchQueryJSON('type:expense footerCurrency:USD'); + const eur = buildSearchQueryJSON('type:expense footerCurrency:EUR'); + + expect(usd?.hash).toEqual(eur?.hash); + expect(usd?.hash).toEqual(noSelection?.hash); + }); + + it('leaves a query carrying only footer selections the default one, since they filter nothing', () => { + const isDefaultExpenses = (query: string) => { + const queryJSON = buildSearchQueryJSON(query); + return queryJSON ? isDefaultExpensesQuery(queryJSON) : undefined; + }; + const isDefaultExpenseReports = (query: string) => { + const queryJSON = buildSearchQueryJSON(query); + return queryJSON ? isDefaultExpenseReportsQuery(queryJSON) : undefined; + }; + + expect(isDefaultExpenses('type:expense footerTotal:billable footerCurrency:EUR')).toBe(true); + expect(isDefaultExpenses('type:expense footerTotal:billable category:Travel')).toBe(false); + expect(isDefaultExpenseReports('type:expense-report footerCount:expenses')).toBe(true); + expect(isDefaultExpenseReports('type:expense-report groupBy:report')).toBe(false); + }); + + it('keeps every footer selection out of the recent and similar search hashes, since none of them changes a result', () => { + const noSelection = buildSearchQueryJSON('type:expense'); + const withSelections = buildSearchQueryJSON('type:expense footerCount:reports footerTotal:billable footerCurrency:EUR'); + + expect(withSelections?.recentSearchHash).toEqual(noSelection?.recentSearchHash); + expect(withSelections?.similarSearchHash).toEqual(noSelection?.similarSearchHash); + }); + + it('drops a footer selection the footer cannot render, so it hashes as no selection at all', () => { + const noSelection = buildSearchQueryJSON('type:expense'); + const junk = buildSearchQueryJSON('type:expense footerCount:pandas footerTotal:whatever'); + + expect(getFooterSelectionFromQuery(junk).footerCount).toBeUndefined(); + expect(getFooterSelectionFromQuery(junk).footerTotal).toBeUndefined(); + expect(junk?.hash).toEqual(noSelection?.hash); + }); + + it('strips every footer selection back out, landing on the snapshot the plain query keys', () => { + const carried = buildSearchQueryJSON('type:expense status:all footerCount:reports footerTotal:reimbursable footerCurrency:EUR'); + const stripped = carried ? buildSearchQueryJSON(getQueryWithoutFooterSelections(carried)) : undefined; + const plain = buildSearchQueryJSON('type:expense status:all'); + + // The same search, so the same snapshot: a reader that ignores the selections asks for nothing of its own + expect(stripped?.hash).toEqual(plain?.hash); + expect(getFooterSelectionFromQuery(stripped).footerCount).toBeUndefined(); + expect(getFooterSelectionFromQuery(stripped).footerTotal).toBeUndefined(); + expect(getFooterSelectionFromQuery(stripped).footerCurrency).toBeUndefined(); + }); + + it('keeps the rest of the query when the footer selections are stripped', () => { + const carried = buildSearchQueryJSON('type:expense sortBy:amount sortOrder:asc category:Travel footerTotal:billable'); + const stripped = carried ? buildSearchQueryJSON(getQueryWithoutFooterSelections(carried)) : undefined; + + expect(stripped?.sortBy).toEqual(CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT); + expect(stripped?.sortOrder).toEqual(CONST.SEARCH.SORT_ORDER.ASC); + expect(stripped?.flatFilters.map((filter) => filter.key)).toEqual([CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY]); + }); + + it('tells a query carrying footer selections from one that is not', () => { + const carried = buildSearchQueryJSON('type:expense footerTotal:billable'); + const plain = buildSearchQueryJSON('type:expense category:Travel'); + + expect(carried ? hasFooterSelections(carried) : undefined).toBe(true); + expect(plain ? hasFooterSelections(plain) : undefined).toBe(false); + }); + + it('reads the footer currency back in upper case, so the same currency in either case is one selection', () => { + const lower = buildSearchQueryJSON('type:expense footerCurrency:eur'); + const upper = buildSearchQueryJSON('type:expense footerCurrency:EUR'); + + expect(getFooterSelectionFromQuery(lower).footerCurrency).toEqual('EUR'); + expect(getFooterSelectionFromQuery(upper).footerCurrency).toEqual('EUR'); + }); + + it('leaves the hashes of a query carrying no footer selection unchanged, so existing saved searches keep theirs', () => { + // Captured before the footer keys existed. These hashes key saved searches and snapshots, so they must not drift. + expect(buildSearchQueryJSON('type:expense')?.hash).toEqual(959171759); + expect(buildSearchQueryJSON('type:expense-report')?.hash).toEqual(1657503970); + expect(buildSearchQueryJSON('type:expense merchant:Amazon')?.hash).toEqual(1692688529); + }); + }); describe('getQueryHashWithoutFilters', () => { const noExcludedFilters = new Set(); @@ -3030,6 +3282,27 @@ describe('SearchQueryUtils', () => { expect(result).toContain('view:table'); }); + test('round-trips the Spend footer selections, normalizing the user-friendly spellings', () => { + const queryJSON = buildSearchQueryJSON('type:expense footer-count:reports footer-total:non-billable footer-currency:EUR'); + + const result = buildSearchQueryString(queryJSON); + + expect(result).toContain('footerCount:reports'); + expect(result).toContain('footerTotal:non-billable'); + expect(result).toContain('footerCurrency:EUR'); + expect(buildSearchQueryJSON(result)?.hash).toEqual(queryJSON?.hash); + }); + + test('omits the Spend footer selections a query does not carry', () => { + const queryJSON = buildSearchQueryJSON('type:expense'); + + const result = buildSearchQueryString(queryJSON); + + expect(result).not.toContain('footerCount:'); + expect(result).not.toContain('footerTotal:'); + expect(result).not.toContain('footerCurrency:'); + }); + test('preserves view along with other filters', () => { const queryJSON = buildSearchQueryJSON('type:expense view:line category:travel'); @@ -4107,6 +4380,61 @@ describe('SearchQueryUtils', () => { return null; } + describe('Spend footer selections and the filters Reset button', () => { + it('does not count a footer selection as a filter change, so Reset stays hidden', () => { + const defaultFooterQuery = buildSearchQueryJSON('type:expense'); + const withFooterSelections = buildSearchQueryJSON('type:expense footerCount:reports footerTotal:billable footerCurrency:EUR'); + + if (!defaultFooterQuery || !withFooterSelections) { + throw new Error('Failed to parse query string'); + } + + expect(hasFiltersChangedFromDefault(withFooterSelections, defaultFooterQuery)).toBe(false); + }); + + it('still counts a real filter as a change', () => { + const defaultFooterQuery = buildSearchQueryJSON('type:expense'); + const withFilter = buildSearchQueryJSON('type:expense merchant:Amazon footerTotal:billable'); + + if (!defaultFooterQuery || !withFilter) { + throw new Error('Failed to parse query string'); + } + + expect(hasFiltersChangedFromDefault(withFilter, defaultFooterQuery)).toBe(true); + }); + + it('keeps the footer selections when the filters are reset', () => { + const defaultFooterQuery = buildSearchQueryJSON('type:expense'); + const current = buildSearchQueryJSON('type:expense merchant:Amazon footerCount:reports footerTotal:billable'); + + if (!current || !defaultFooterQuery) { + throw new Error('Failed to parse query string'); + } + + const reset = buildQueryStringWithResetFilters(current, defaultFooterQuery); + + expect(reset).not.toContain('merchant'); + expect(reset).toContain('footerCount:reports'); + expect(reset).toContain('footerTotal:billable'); + }); + }); + + describe('Spend footer selections sent to the backend', () => { + it('serializes the footer selections inside the filters, which is where the backend reads them', () => { + const queryJSON = buildSearchQueryJSON('type:expense footerCount:reports footerTotal:billable footerCurrency:EUR'); + + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + + const serialized = serializeQueryJSONForBackend(queryJSON); + + expect(serialized).toContain('"left":"footerTotal","right":"billable"'); + expect(serialized).toContain('"left":"footerCount","right":"reports"'); + expect(serialized).toContain('"left":"footerCurrency","right":"EUR"'); + }); + }); + describe('applyContainsOperatorToTextFields', () => { it('should transform merchant eq to contains', () => { const queryJSON = buildSearchQueryJSON('type:expense merchant:coffee'); diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx index d8192fd93c12..623ee2fbdf76 100644 --- a/tests/unit/Search/SearchSelectionFooterTest.tsx +++ b/tests/unit/Search/SearchSelectionFooterTest.tsx @@ -1,9 +1,10 @@ import {act, render} from '@testing-library/react-native'; -import SearchSelectionFooter from '@components/Search/SearchSelectionFooter'; -import type {SearchGroupBy, SelectedReports, SelectedTransactionInfo, SelectedTransactions} from '@components/Search/types'; +import SearchSelectionFooter, {resetAnsweredFooterTotalsForTesting} from '@components/Search/SearchSelectionFooter'; +import type {SearchFooterCount, SearchFooterTotal, SearchQueryJSON, SelectedReports, SelectedTransactionInfo, SelectedTransactions} from '@components/Search/types'; import {getFooterConvertedAmounts} from '@libs/actions/Search'; +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -11,35 +12,74 @@ import type {SearchResults} from '@src/types/onyx'; import Onyx from 'react-native-onyx'; +import createRandomTransaction from '../../utils/collections/transaction'; import {makeSettlementGroup} from '../../utils/ExpensifyCardStatementTestUtils'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; -jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); +const mockIsOffline = {current: false}; +jest.mock('@hooks/useNetwork', () => () => ({isOffline: mockIsOffline.current})); jest.mock('@hooks/useSearchShouldCalculateTotals', () => jest.fn(() => true)); +const mockIsFooterSelectorsBetaEnabled = {current: true}; +jest.mock('@hooks/usePermissions', () => ({ + __esModule: true, + default: () => ({ + isBetaEnabled: (beta: string) => (beta === 'spendFooterSelectors' ? mockIsFooterSelectorsBetaEnabled.current : false), + isBetaEnabledOrUnknown: () => false, + }), +})); + +const mockSearch = jest.fn | undefined, unknown[]>(); jest.mock('@libs/actions/Search', () => ({ openSearchCardFiltersPage: jest.fn(), getFooterConvertedAmounts: jest.fn(), + search: (...args: unknown[]) => mockSearch(...args), +})); + +const mockSetParams = jest.fn(); +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + // Called through a wrapper: the factory runs before the mock above is initialized. + default: {setParams: (params: {q?: string; rawQuery?: string}) => mockSetParams(params)}, +})); + +// The real one waits for the popover to close first. The callback is all these tests care about. +jest.mock('@libs/actions/Modal', () => ({ + close: (onModalClose: () => void) => onModalClose(), })); type MockSearchQueryContext = { currentSearchHash: number; currentSearchKey: undefined; - currentSearchQueryJSON: {hash: number; type: SearchResults['search']['type']; groupBy?: SearchGroupBy} | undefined; + currentSearchQueryJSON: Readonly | undefined; }; const mockSearchQueryContext: {current: MockSearchQueryContext} = { - current: {currentSearchHash: 1, currentSearchKey: undefined, currentSearchQueryJSON: {hash: 1, type: CONST.SEARCH.DATA_TYPES.EXPENSE}}, + current: {currentSearchHash: 1, currentSearchKey: undefined, currentSearchQueryJSON: buildSearchQueryJSON('type:expense')}, }; + +// The footer reads its selections out of the query's filters, so tests drive it with real parsed queries. The hash is +// kept separate from the query's own: the snapshot the footer displays is stamped with hash 1 throughout, and a +// mismatch between the two is what tells the footer a re-run is in flight. +function setSearchQuery(query: string, currentSearchHash = 1) { + mockSearchQueryContext.current = {currentSearchHash, currentSearchKey: undefined, currentSearchQueryJSON: buildSearchQueryJSON(query)}; +} const mockSelectedTransactions: {current: SelectedTransactions} = {current: {}}; const mockExcludedTransactions: {current: SelectedTransactions} = {current: {}}; const mockSelectedReports: {current: SelectedReports[]} = {current: []}; const mockAreAllMatchingItemsSelected = {current: false}; +// The loaded rows of each report, which is what tells a report excluded down to its last row from a part-excluded one. +const mockTransactionsByReportID: {current: Map>} = {current: new Map()}; +const mockShouldUseLiveData = {current: false}; const mockCurrentSearchResults: {current: SearchResults | undefined} = {current: undefined}; jest.mock('@components/Search/SearchContext', () => ({ useSearchQueryContext: () => mockSearchQueryContext.current, - useSearchResultsContext: () => ({currentSearchResults: mockCurrentSearchResults.current}), + useSearchResultsContext: () => ({ + currentSearchResults: mockCurrentSearchResults.current, + currentSearchTransactionsByReportID: mockTransactionsByReportID.current, + shouldUseLiveData: mockShouldUseLiveData.current, + }), useSearchSelectionContext: () => ({ selectedTransactions: mockSelectedTransactions.current, excludedTransactions: mockExcludedTransactions.current, @@ -50,10 +90,17 @@ jest.mock('@components/Search/SearchContext', () => ({ type CapturedFooterProps = { count?: number; + countType?: SearchFooterCount; + defaultCountType?: SearchFooterCount; total?: number; + totalType?: SearchFooterTotal; + isTotalLoading?: boolean; + shouldShowTotalSelector?: boolean; defaultCurrency?: string; currency?: string; onCurrencyChange?: (currency: string) => void; + onCountChange?: (countType: SearchFooterCount) => void; + onTotalChange?: (totalType: SearchFooterTotal) => void; }; const mockCapturedFooterProps: {current: CapturedFooterProps | undefined} = {current: undefined}; jest.mock('@components/Search/SearchPageFooter', () => ({ @@ -81,11 +128,13 @@ function buildSearchResults( count = 1, total = -100, type: SearchResults['search']['type'] = CONST.SEARCH.DATA_TYPES.EXPENSE, + reportCount: number | undefined = undefined, hasMoreResults = false, ): SearchResults { return { search: { count, + reportCount, currency, total, offset: 0, @@ -101,6 +150,18 @@ function buildSearchResults( }; } +// An expense of `amount`, carrying the flags the total aggregates classify on. Expenses display as a negative amount, +// and the footer totals the displayed amounts, so a selection of expenses sums to a negative figure. +function buildFlaggedTransaction(amount: number, flags: {reimbursable?: boolean; billable?: boolean}): SelectedTransactionInfo { + return { + ...buildSelectedTransaction(CONST.CURRENCY.USD), + amount, + displayAmount: -amount, + // The aggregates classify on the row's own transaction, which is what the search list attaches to a selection. + transaction: {...createRandomTransaction(amount), reimbursable: flags.reimbursable, billable: flags.billable}, + }; +} + function buildSelectedTransaction(currency: string, groupCurrency?: string, groupAmount?: number, reportID?: string): SelectedTransactionInfo { return { isSelected: true, @@ -143,11 +204,16 @@ describe('SearchSelectionFooter', () => { }); beforeEach(async () => { - mockSearchQueryContext.current = {currentSearchHash: 1, currentSearchKey: undefined, currentSearchQueryJSON: {hash: 1, type: CONST.SEARCH.DATA_TYPES.EXPENSE}}; + mockIsFooterSelectorsBetaEnabled.current = true; + mockShouldUseLiveData.current = false; + mockIsOffline.current = false; + resetAnsweredFooterTotalsForTesting(); + setSearchQuery('type:expense'); mockSelectedTransactions.current = {transaction1: buildSelectedTransaction(SELECTED_EXPENSE_CURRENCY)}; mockExcludedTransactions.current = {}; mockSelectedReports.current = []; mockAreAllMatchingItemsSelected.current = false; + mockTransactionsByReportID.current = new Map(); mockCurrentSearchResults.current = undefined; mockCapturedFooterProps.current = undefined; // Clear here rather than in afterEach: Onyx.clear() there re-renders the previous test's still-mounted @@ -176,11 +242,7 @@ describe('SearchSelectionFooter', () => { }); it("subtracts an excluded report's expenses and total from the all-matching footer", async () => { - mockSearchQueryContext.current = { - currentSearchHash: 1, - currentSearchKey: undefined, - currentSearchQueryJSON: {hash: 1, type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT}, - }; + setSearchQuery('type:expense-report footerCount:expenses'); mockSelectedTransactions.current = {transaction3: buildSelectedTransaction(CONST.CURRENCY.USD, undefined, -100, 'report2')}; mockExcludedTransactions.current = { transaction1: buildSelectedTransaction(CONST.CURRENCY.USD, undefined, -100, 'report1'), @@ -195,46 +257,96 @@ describe('SearchSelectionFooter', () => { expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 8, total: 35800, currency: CONST.CURRENCY.USD})); }); - it('shows the authoritative expense count and total before every report page is loaded', async () => { - mockSearchQueryContext.current = { - currentSearchHash: 1, - currentSearchKey: undefined, - currentSearchQueryJSON: {hash: 1, type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT}, + it('takes a report the user unchecked off the report count of a select-all', async () => { + // Given a select-all over more reports than are loaded, which is the only way the footer is on the server's count + setSearchQuery('type:expense-report footerCount:reports'); + mockSelectedTransactions.current = {}; + mockTransactionsByReportID.current = new Map([ + ['report1', [{transactionID: 'transaction1'}, {transactionID: 'transaction2'}]], + ['report2', [{transactionID: 'transaction3'}]], + ]); + mockAreAllMatchingItemsSelected.current = true; + + // When one report is unchecked, which excludes every expense it holds + mockExcludedTransactions.current = { + transaction1: buildSelectedTransaction(CONST.CURRENCY.USD, undefined, -100, 'report1'), + transaction2: buildSelectedTransaction(CONST.CURRENCY.USD, undefined, -100, 'report1'), }; + + render(); + await waitForBatchedUpdates(); + + // Then it comes off the server's whole-search report count + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.REPORTS, count: 86})); + }); + + it('takes an empty report the user unchecked off the report count of a select-all', async () => { + // Given a select-all over more reports than are loaded, with an empty draft report among them + setSearchQuery('type:expense-report footerCount:reports'); + mockSelectedTransactions.current = {}; + mockTransactionsByReportID.current = new Map([['report1', [{transactionID: 'transaction1'}]]]); + mockAreAllMatchingItemsSelected.current = true; + + // When the empty report is unchecked, which excludes it under its own report key since it holds no expense + mockExcludedTransactions.current = {report2: buildSelectedTransaction(CONST.CURRENCY.USD, undefined, 0, 'report2')}; + + render(); + await waitForBatchedUpdates(); + + // Then it comes off the server's whole-search report count like any other report + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.REPORTS, count: 86})); + }); + + it('keeps a part-unchecked report in the report count, since the selection still covers it', async () => { + // Given the same select-all, with only one of a report's two expenses unchecked + setSearchQuery('type:expense-report footerCount:reports'); + mockSelectedTransactions.current = {}; + mockTransactionsByReportID.current = new Map([['report1', [{transactionID: 'transaction1'}, {transactionID: 'transaction2'}]]]); + mockAreAllMatchingItemsSelected.current = true; + mockExcludedTransactions.current = {transaction1: buildSelectedTransaction(CONST.CURRENCY.USD, undefined, -100, 'report1')}; + + render(); + await waitForBatchedUpdates(); + + // Then the report still counts, because the selection still holds its other expense + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.REPORTS, count: 87})); + }); + + it('shows the authoritative expense count and total before every report page is loaded', async () => { + setSearchQuery('type:expense-report'); mockSelectedTransactions.current = {transaction1: buildSelectedTransaction(CONST.CURRENCY.USD, undefined, -100, 'report1')}; mockSelectedReports.current = [buildSelectedReport('report1', -100)]; mockAreAllMatchingItemsSelected.current = true; - render(); + render(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 10, total: 36000, currency: CONST.CURRENCY.USD})); }); - it('counts the expenses inside manually selected reports', async () => { - mockSearchQueryContext.current = { - currentSearchHash: 1, - currentSearchKey: undefined, - currentSearchQueryJSON: {hash: 1, type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT}, - }; + it('counts manually selected reports, and their expenses once the count selector says so', async () => { + // Two expenses of one report: one report, two expenses. A Reports search counts reports unless told otherwise. + setSearchQuery('type:expense-report'); mockSelectedTransactions.current = { transaction1: buildSelectedTransaction(CONST.CURRENCY.USD, undefined, -100, 'report1'), transaction2: buildSelectedTransaction(CONST.CURRENCY.USD, undefined, -100, 'report1'), }; mockSelectedReports.current = [buildSelectedReport('report1', -200)]; - render(); + const {rerender} = render(); await waitForBatchedUpdates(); - expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 2, total: 200, currency: CONST.CURRENCY.USD})); + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.REPORTS, count: 1, total: 200, currency: CONST.CURRENCY.USD})); + + setSearchQuery('type:expense-report footerCount:expenses'); + rerender(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.EXPENSES, count: 2, total: 200, currency: CONST.CURRENCY.USD})); }); it('does not request the same report conversion twice before the optimistic source stamp is observed', async () => { - mockSearchQueryContext.current = { - currentSearchHash: 1, - currentSearchKey: undefined, - currentSearchQueryJSON: {hash: 1, type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT}, - }; + setSearchQuery('type:expense-report'); mockSelectedTransactions.current = {transaction2: buildSelectedTransaction(CONST.CURRENCY.USD, undefined, -100, 'report2')}; mockExcludedTransactions.current = { transaction1: buildSelectedTransaction(CONST.CURRENCY.USD, undefined, -100, 'report1'), @@ -266,6 +378,80 @@ describe('SearchSelectionFooter', () => { ); }); + it('subtracts only the matching part of a report excluded under a breakdown in another currency', async () => { + // Given a select-all on the Reports search, on the reimbursable breakdown in EUR, with its converted total fresh + setSearchQuery(`type:expense-report footerTotal:${CONST.SEARCH.FOOTER_TOTAL.REIMBURSABLE} footerCurrency:${CONST.CURRENCY.EUR}`); + mockSelectedTransactions.current = {}; + mockAreAllMatchingItemsSelected.current = true; + + // When a report holding $60 reimbursable and $40 non-reimbursable is unchecked, with its whole EUR total cached + mockExcludedTransactions.current = { + transaction1: {...buildFlaggedTransaction(-6000, {reimbursable: true}), reportID: 'report1'}, + transaction2: {...buildFlaggedTransaction(-4000, {reimbursable: false}), reportID: 'report1'}, + }; + const searchHash = mockSearchQueryContext.current.currentSearchHash; + await Onyx.merge(ONYXKEYS.SEARCH_FOOTER_CONVERSION, { + searchTotals: {[searchHash]: {[CONST.CURRENCY.EUR]: {count: 10, total: 30000}}}, + reports: {report1: {[CONST.CURRENCY.EUR]: -9000}}, + sources: {searchTotals: {[searchHash]: {[CONST.CURRENCY.EUR]: 36000}}, reports: {report1: {[CONST.CURRENCY.EUR]: -10000}}}, + }); + + render(); + await waitForBatchedUpdates(); + + // Then only its reimbursable $60 comes off, in the default currency, since the report's EUR total has no breakdown + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({total: 30000, currency: CONST.CURRENCY.USD, isTotalLoading: false})); + }); + + describe('a collapsed group unchecked under a select-all', () => { + const GROUP_KEY = `${CONST.SEARCH.GROUP_PREFIX}travel` as const; + + const renderWithExcludedGroup = async (query: string) => { + // Given a select-all on a grouped search, with a collapsed group of three expenses unchecked as one entry + setSearchQuery(query); + mockSelectedTransactions.current = {}; + mockAreAllMatchingItemsSelected.current = true; + mockExcludedTransactions.current = {[GROUP_KEY]: buildSelectedTransaction(CONST.CURRENCY.USD, CONST.CURRENCY.USD, -50000)}; + const searchResults = buildSearchResults(CONST.CURRENCY.USD, 172, 80000, CONST.SEARCH.DATA_TYPES.EXPENSE, undefined, true); + mockCurrentSearchResults.current = {...searchResults, data: {[GROUP_KEY]: makeSettlementGroup({count: 3, total: 50000})}}; + + render(); + await waitForBatchedUpdates(); + }; + + it('shows no total under a breakdown, since none of the group can be told apart by it', async () => { + // When the footer describes the reimbursable breakdown + await renderWithExcludedGroup(`type:expense group-by:category footerTotal:${CONST.SEARCH.FOOTER_TOTAL.REIMBURSABLE}`); + + // Then the group still comes off the count, but no figure is shown that would still include it + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 169, total: undefined})); + }); + + it('keeps the total selector under a breakdown, so the plain total can still be picked', async () => { + // When the footer describes the reimbursable breakdown, which leaves it no figure to show + await renderWithExcludedGroup(`type:expense group-by:category footerTotal:${CONST.SEARCH.FOOTER_TOTAL.REIMBURSABLE}`); + + // Then the selector stays, instead of leaving the footer on a breakdown it cannot switch away from + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({totalType: CONST.SEARCH.FOOTER_TOTAL.REIMBURSABLE, shouldShowTotalSelector: true})); + }); + + it('hides the total selector for the excluded group on the plain total, as for any group selection', async () => { + // When the footer describes the plain total + await renderWithExcludedGroup('type:expense group-by:category'); + + // Then the group selection keeps the selector out, as before + expect(mockCapturedFooterProps.current?.shouldShowTotalSelector).toBe(false); + }); + + it('still takes the group off the plain total', async () => { + // When the footer describes the plain total + await renderWithExcludedGroup('type:expense group-by:category'); + + // Then the group's whole amount comes off, as before + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 169, total: 30000})); + }); + }); + it('nets a selected credit against a selected expense instead of summing their magnitudes', async () => { mockSelectedTransactions.current = { transaction1: {...buildSelectedTransaction(CONST.CURRENCY.USD), displayAmount: 10000}, @@ -364,19 +550,846 @@ describe('SearchSelectionFooter', () => { expect(mockCapturedFooterProps.current?.currency).toBe(PAYMENT_CURRENCY); }); + describe('total selector', () => { + it('defaults to the plain total spend and reads the aggregate the query selects', async () => { + mockSelectedTransactions.current = {}; + + const {rerender} = render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({totalType: CONST.SEARCH.FOOTER_TOTAL.TOTAL, total: 36000})); + + setSearchQuery('type:expense footerTotal:reimbursable'); + // The backend swaps the aggregate into `total` itself, so the footer just displays the total it was sent. + const searchResults = buildSearchResults(CONST.CURRENCY.USD, 10, 12000); + + rerender(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({totalType: CONST.SEARCH.FOOTER_TOTAL.REIMBURSABLE, total: 12000})); + }); + + it('falls back to the plain total when the backend answers with the aggregate in `total` instead of its own field', async () => { + setSearchQuery('type:expense footerTotal:billable'); + mockSelectedTransactions.current = {}; + + render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current?.total).toBe(4200); + }); + + it('hides a total offline once the breakdown has moved on from the one it was answered for', async () => { + // Given a billable total answered online + setSearchQuery('type:expense footerTotal:billable'); + mockSelectedTransactions.current = {}; + const {rerender} = render(); + await waitForBatchedUpdates(); + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({totalType: CONST.SEARCH.FOOTER_TOTAL.BILLABLE, total: 229})); + + // When offline, the query moves to the plain total + mockIsOffline.current = true; + setSearchQuery('type:expense'); + rerender(); + await waitForBatchedUpdates(); + + // Then the billable figure is not shown as "Total spend" + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({totalType: CONST.SEARCH.FOOTER_TOTAL.TOTAL, total: undefined})); + }); + + it('shows the plain total offline from when it was last answered, instead of hiding it', async () => { + // Given the plain total answered online, then the billable one, on the same search + setSearchQuery('type:expense'); + mockSelectedTransactions.current = {}; + const {rerender} = render(); + await waitForBatchedUpdates(); + setSearchQuery('type:expense footerTotal:billable'); + rerender(); + await waitForBatchedUpdates(); + + // When offline, back to the plain total, on a snapshot still holding 229 + mockIsOffline.current = true; + setSearchQuery('type:expense'); + rerender(); + await waitForBatchedUpdates(); + + // Then the earlier plain total is shown + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({totalType: CONST.SEARCH.FOOTER_TOTAL.TOTAL, total: 342})); + }); + + it('never shows another account its recorded totals', async () => { + // Given the plain total and then the billable one answered online for this account + setSearchQuery('type:expense'); + mockSelectedTransactions.current = {}; + const first = render(); + await waitForBatchedUpdates(); + setSearchQuery('type:expense footerTotal:billable'); + first.rerender(); + await waitForBatchedUpdates(); + first.unmount(); + + // When another account signs in and, offline, opens the plain total on the same query, whose snapshot is + // stamped for billable + await Onyx.merge(ONYXKEYS.SESSION, {accountID: ACCOUNT_ID + 1}); + mockIsOffline.current = true; + setSearchQuery('type:expense'); + const billableSnapshot = buildSearchResults(CONST.CURRENCY.USD, 10, 229); + billableSnapshot.search.footerTotal = CONST.SEARCH.FOOTER_TOTAL.BILLABLE; + render(); + await waitForBatchedUpdates(); + + // Then the first account's plain total is not shown to it + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({totalType: CONST.SEARCH.FOOTER_TOTAL.TOTAL, total: undefined})); + }); + + it('keeps showing it after another tab remounts the footer', async () => { + setSearchQuery('type:expense'); + mockSelectedTransactions.current = {}; + const first = render(); + await waitForBatchedUpdates(); + setSearchQuery('type:expense footerTotal:billable'); + first.rerender(); + await waitForBatchedUpdates(); + + // When remounted offline on the plain query + first.unmount(); + mockIsOffline.current = true; + setSearchQuery('type:expense'); + render(); + await waitForBatchedUpdates(); + + // Then both answers survived the remount + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({totalType: CONST.SEARCH.FOOTER_TOTAL.TOTAL, total: 342})); + }); + + it('keeps both answers when a search response sets the snapshot between them', async () => { + // Given the plain total, then a Billable response that replaces the snapshot + setSearchQuery('type:expense'); + mockSelectedTransactions.current = {}; + const {rerender} = render(); + await waitForBatchedUpdates(); + setSearchQuery('type:expense footerTotal:billable'); + rerender(); + await waitForBatchedUpdates(); + + // When offline, back to the plain total + mockIsOffline.current = true; + setSearchQuery('type:expense'); + rerender(); + await waitForBatchedUpdates(); + + // Then the plain answer survived, since it is not stored on the snapshot + expect(mockCapturedFooterProps.current?.total).toBe(342); + }); + + it('shows the total again once back online, where the query change refreshes it', async () => { + // Given the stale state above + setSearchQuery('type:expense footerTotal:billable'); + mockSelectedTransactions.current = {}; + const {rerender} = render(); + await waitForBatchedUpdates(); + mockIsOffline.current = true; + setSearchQuery('type:expense'); + rerender(); + await waitForBatchedUpdates(); + + // When the network returns and the refreshed plain total lands + mockIsOffline.current = false; + rerender(); + await waitForBatchedUpdates(); + + // Then it is shown again + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({totalType: CONST.SEARCH.FOOTER_TOTAL.TOTAL, total: 342})); + }); + + it('asks nothing of the backend for a breakdown switched to offline, leaving the recorded figure in place', async () => { + // Given the plain total answered online + setSearchQuery('type:expense'); + mockSelectedTransactions.current = {}; + const {rerender} = render(); + await waitForBatchedUpdates(); + + // When billable is picked offline + mockIsOffline.current = true; + rerender(); + await waitForBatchedUpdates(); + await act(async () => { + mockCapturedFooterProps.current?.onTotalChange?.(CONST.SEARCH.FOOTER_TOTAL.BILLABLE); + await waitForBatchedUpdates(); + }); + + // Then no request is sent, since none could be answered, so the footer is not left waiting on one + expect(mockSearch).not.toHaveBeenCalled(); + expect(mockCapturedFooterProps.current?.isTotalLoading).toBe(false); + }); + + it('hides a total offline after a reload when the snapshot is stamped for another breakdown', async () => { + // Given a reload offline, which empties the recorded answers, over a snapshot whose total answers billable + mockIsOffline.current = true; + setSearchQuery('type:expense'); + mockSelectedTransactions.current = {}; + const billableResults = buildSearchResults(CONST.CURRENCY.USD, 10, 229); + billableResults.search.footerTotal = CONST.SEARCH.FOOTER_TOTAL.BILLABLE; + + render(); + await waitForBatchedUpdates(); + + // Then the billable figure is not shown as "Total spend" + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({totalType: CONST.SEARCH.FOOTER_TOTAL.TOTAL, total: undefined})); + }); + + it('shows a total offline after a reload when the snapshot is stamped for the breakdown on display', async () => { + // Given a reload offline over a snapshot whose total answers the plain total + mockIsOffline.current = true; + setSearchQuery('type:expense'); + mockSelectedTransactions.current = {}; + const plainResults = buildSearchResults(CONST.CURRENCY.USD, 10, 342); + plainResults.search.footerTotal = CONST.SEARCH.FOOTER_TOTAL.TOTAL; + + render(); + await waitForBatchedUpdates(); + + // Then its figure is shown + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({totalType: CONST.SEARCH.FOOTER_TOTAL.TOTAL, total: 342})); + }); + + it('keeps a total offline when the breakdown is the one it was answered for', async () => { + setSearchQuery('type:expense footerTotal:billable'); + mockSelectedTransactions.current = {}; + const {rerender} = render(); + await waitForBatchedUpdates(); + + mockIsOffline.current = true; + rerender(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({totalType: CONST.SEARCH.FOOTER_TOTAL.BILLABLE, total: 229})); + }); + + it('offers no total selector on a search type that keeps no footer selection', async () => { + // Given an invoice search with totals, where footer selections are not kept + setSearchQuery('type:invoice'); + mockSelectedTransactions.current = {}; + + render(); + await waitForBatchedUpdates(); + + // Then the footer keeps the plain total with no selector, as on main + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({shouldShowTotalSelector: false, totalType: undefined})); + }); + + it('offers no total selector on an empty result set', async () => { + mockSelectedTransactions.current = {}; + + render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current?.totalType).toBeUndefined(); + }); + + it('asks the backend for the new aggregate itself, against the search the list is already reading', async () => { + setSearchQuery('type:expense'); + mockSelectedTransactions.current = {}; + const beforeHash = mockSearchQueryContext.current.currentSearchQueryJSON?.hash; + + render(); + await waitForBatchedUpdates(); + + await act(async () => { + mockCapturedFooterProps.current?.onTotalChange?.(CONST.SEARCH.FOOTER_TOTAL.REIMBURSABLE); + await waitForBatchedUpdates(); + }); + + // The choice goes into the query, which saves it for the next visit... + const nextQuery = mockSetParams.mock.calls.at(0)?.at(0)?.q ?? ''; + expect(nextQuery).toContain('footerTotal:reimbursable'); + // ...and the hash holds, so the list keeps its rows, its pages and its selection. + expect(buildSearchQueryJSON(nextQuery)?.hash).toBe(beforeHash); + + // Nothing re-runs the search on a hash that did not move, so the footer asks for the aggregate itself. + expect(mockSearch).toHaveBeenCalledWith(expect.objectContaining({shouldCalculateTotals: true, queryJSON: expect.objectContaining({hash: beforeHash})})); + }); + + it('skeletons the total while the aggregate it asked for is on its way, leaving the count alone', async () => { + // Given the footer describing the whole search + setSearchQuery('type:expense'); + mockSelectedTransactions.current = {}; + let resolveSearch: () => void = () => {}; + mockSearch.mockImplementation( + () => + new Promise((resolve) => { + resolveSearch = resolve; + }), + ); + + render(); + await waitForBatchedUpdates(); + + // Then nothing is waited on until the footer asks for something + expect(mockCapturedFooterProps.current?.isTotalLoading).toBe(false); + + // When a different total is applied, which the footer asks the backend for + await act(async () => { + mockCapturedFooterProps.current?.onTotalChange?.(CONST.SEARCH.FOOTER_TOTAL.BILLABLE); + await waitForBatchedUpdates(); + }); + + // Then the skeleton stands in for the figure it cannot describe yet, and the count holds its value + expect(mockCapturedFooterProps.current?.isTotalLoading).toBe(true); + expect(mockCapturedFooterProps.current?.count).toBe(10); + + // When the request answers + await act(async () => { + resolveSearch(); + await waitForBatchedUpdates(); + }); + + // Then the skeleton gives way to the figure + expect(mockCapturedFooterProps.current?.isTotalLoading).toBe(false); + }); + + it('asks again for a breakdown the snapshot total does not answer, holding the skeleton until it does', async () => { + // Given the footer on non-billable, over a snapshot whose total last answered billable, as when the + // non-billable request was dropped or overtaken by the billable one + const nonBillableQuery = `type:expense footerTotal:${CONST.SEARCH.FOOTER_TOTAL.NON_BILLABLE}`; + setSearchQuery(nonBillableQuery); + mockSelectedTransactions.current = {}; + const billableResults = buildSearchResults(CONST.CURRENCY.USD, 10, 36000); + billableResults.search.footerTotal = CONST.SEARCH.FOOTER_TOTAL.BILLABLE; + + const {rerender} = render(); + await waitForBatchedUpdates(); + + // Then the billable figure is never shown under the non-billable label, and the footer asks for non-billable + expect(mockCapturedFooterProps.current?.isTotalLoading).toBe(true); + expect(mockSearch).toHaveBeenCalledWith(expect.objectContaining({shouldCalculateTotals: true, queryJSON: expect.objectContaining({inputQuery: nonBillableQuery})})); + + // When the non-billable answer lands + const nonBillableResults = buildSearchResults(CONST.CURRENCY.USD, 10, 12000); + nonBillableResults.search.footerTotal = CONST.SEARCH.FOOTER_TOTAL.NON_BILLABLE; + rerender(); + await waitForBatchedUpdates(); + + // Then its figure is shown + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({isTotalLoading: false, total: 12000})); + }); + + it('shows a snapshot total that answers the breakdown on display without asking again', async () => { + // Given the footer on billable, over a snapshot whose total answers billable + setSearchQuery(`type:expense footerTotal:${CONST.SEARCH.FOOTER_TOTAL.BILLABLE}`); + mockSelectedTransactions.current = {}; + const billableResults = buildSearchResults(CONST.CURRENCY.USD, 10, 36000); + billableResults.search.footerTotal = CONST.SEARCH.FOOTER_TOTAL.BILLABLE; + + render(); + await waitForBatchedUpdates(); + + // Then the figure is shown and nothing is asked for + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({isTotalLoading: false, total: 36000})); + expect(mockSearch).not.toHaveBeenCalled(); + }); + + it('never waits on a search for a to-do search, whose totals are summed from live data', async () => { + // Given a to-do search (e.g. Drafts), whose results are built from live Onyx data and carry no snapshot + // hash a wait could ever end on + mockShouldUseLiveData.current = true; + setSearchQuery('type:expense', 2); + mockSelectedTransactions.current = {}; + + const {rerender} = render(); + await waitForBatchedUpdates(); + + // When a different total is applied and the query moves onto its own hash + await act(async () => { + mockCapturedFooterProps.current?.onTotalChange?.(CONST.SEARCH.FOOTER_TOTAL.BILLABLE); + await waitForBatchedUpdates(); + }); + const nextQuery = mockSetParams.mock.calls.at(0)?.at(0)?.q ?? ''; + const nextHash = buildSearchQueryJSON(nextQuery)?.hash ?? 0; + setSearchQuery(nextQuery, nextHash); + // Live results keep the hash they were stamped with, so it never catches up to the query's + rerender(); + await waitForBatchedUpdates(); + + // Then the figure is shown straight away instead of a skeleton that could never resolve + expect(mockCapturedFooterProps.current?.isTotalLoading).toBe(false); + expect(nextQuery).toContain('footerTotal:billable'); + }); + + it('leaves the total alone while a search the footer did not ask for runs, so it does not flicker', async () => { + // A select-all re-requests the same totals it is already displaying, and a sort keeps the previous figures. + setSearchQuery('type:expense', 2); + mockSelectedTransactions.current = {}; + + render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current?.isTotalLoading).toBe(false); + }); + }); + + describe('total selector over a selection', () => { + beforeEach(() => { + setSearchQuery('type:expense'); + mockSelectedTransactions.current = { + reimbursable1: buildFlaggedTransaction(100, {reimbursable: true, billable: true}), + reimbursable2: buildFlaggedTransaction(200, {reimbursable: true, billable: false}), + nonReimbursable1: buildFlaggedTransaction(300, {reimbursable: false, billable: false}), + }; + }); + + async function renderWithTotal(totalType: SearchFooterTotal) { + setSearchQuery(`type:expense footerTotal:${totalType}`); + render(); + await waitForBatchedUpdates(); + return mockCapturedFooterProps.current; + } + + it('sums every selected expense for the plain total', async () => { + expect(await renderWithTotal(CONST.SEARCH.FOOTER_TOTAL.TOTAL)).toEqual(expect.objectContaining({total: -600})); + }); + + it('sums only the reimbursable selected expenses', async () => { + expect(await renderWithTotal(CONST.SEARCH.FOOTER_TOTAL.REIMBURSABLE)).toEqual(expect.objectContaining({total: -300})); + }); + + it('sums only the non-reimbursable selected expenses', async () => { + expect(await renderWithTotal(CONST.SEARCH.FOOTER_TOTAL.NON_REIMBURSABLE)).toEqual(expect.objectContaining({total: -300})); + }); + + it('sums only the billable selected expenses', async () => { + expect(await renderWithTotal(CONST.SEARCH.FOOTER_TOTAL.BILLABLE)).toEqual(expect.objectContaining({total: -100})); + }); + + it('sums only the non-billable selected expenses', async () => { + expect(await renderWithTotal(CONST.SEARCH.FOOTER_TOTAL.NON_BILLABLE)).toEqual(expect.objectContaining({total: -500})); + }); + + it('labels a breakdown of selected reports in the default currency, since a Reports search converts by report', async () => { + setSearchQuery(`type:expense-report footerTotal:${CONST.SEARCH.FOOTER_TOTAL.REIMBURSABLE}`); + mockSelectedTransactions.current = {reimbursable1: buildFlaggedTransaction(100, {reimbursable: true})}; + + render(); + await waitForBatchedUpdates(); + + await act(async () => { + mockCapturedFooterProps.current?.onCurrencyChange?.(CONST.CURRENCY.EUR); + await waitForBatchedUpdates(); + }); + + // A per-report converted total has no breakdown inside it, and the reports' expenses are not converted + // individually, so the figure stays in the currency it is actually denominated in. + expect(mockCapturedFooterProps.current?.currency).not.toBe(CONST.CURRENCY.EUR); + }); + + it('stores a total applied over a selection in the query, which is what the sum then follows', async () => { + // Given a hand-picked selection of three expenses, one of them billable + setSearchQuery('type:expense'); + const {rerender} = render(); + await waitForBatchedUpdates(); + + // When Billable is applied + await act(async () => { + mockCapturedFooterProps.current?.onTotalChange?.(CONST.SEARCH.FOOTER_TOTAL.BILLABLE); + await waitForBatchedUpdates(); + }); + + // Then the choice goes into the query like any other, so it survives a reload and a saved search, without + // asking the backend for anything: the sum is the client's own + const nextQuery = mockSetParams.mock.calls.at(0)?.at(0)?.q ?? ''; + expect(nextQuery).toContain('footerTotal:billable'); + expect(mockSearch).not.toHaveBeenCalled(); + + // And once the app is on that query, the selection is summed by it: one of the three is billable + setSearchQuery(nextQuery); + rerender(); + await waitForBatchedUpdates(); + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({totalType: CONST.SEARCH.FOOTER_TOTAL.BILLABLE, total: -100})); + }); + + it('counts a row with no reimbursable flag as reimbursable, the same as its own column renders it', async () => { + mockSelectedTransactions.current = { + flagless: buildFlaggedTransaction(100, {}), + nonReimbursable: buildFlaggedTransaction(200, {reimbursable: false}), + }; + + expect(await renderWithTotal(CONST.SEARCH.FOOTER_TOTAL.REIMBURSABLE)).toEqual(expect.objectContaining({total: -100})); + expect(await renderWithTotal(CONST.SEARCH.FOOTER_TOTAL.NON_REIMBURSABLE)).toEqual(expect.objectContaining({total: -200})); + }); + + it('shows zero when no selected expense matches the total, rather than hiding the option', async () => { + mockSelectedTransactions.current = { + nonBillable1: buildFlaggedTransaction(100, {reimbursable: true, billable: false}), + nonBillable2: buildFlaggedTransaction(200, {reimbursable: true, billable: false}), + }; + + expect(await renderWithTotal(CONST.SEARCH.FOOTER_TOTAL.BILLABLE)).toEqual(expect.objectContaining({totalType: CONST.SEARCH.FOOTER_TOTAL.BILLABLE, total: 0})); + }); + }); + + describe('currency selector', () => { + it('converts through GetTransactionsConvertedAmount and carries the choice in the query, without re-running the search', async () => { + setSearchQuery('type:expense'); + mockSelectedTransactions.current = {}; + + render(); + await waitForBatchedUpdates(); + + await act(async () => { + mockCapturedFooterProps.current?.onCurrencyChange?.(CONST.CURRENCY.EUR); + await waitForBatchedUpdates(); + }); + + // Search ignores footerCurrency, so the conversion is requested separately... + expect(getFooterConvertedAmounts).toHaveBeenCalledWith(expect.objectContaining({targetCurrency: CONST.CURRENCY.EUR})); + // ...while the query carries the choice so it survives a reload, with no results reload of its own. + expect(mockSetParams.mock.calls.at(0)?.at(0)?.q).toContain('footerCurrency:EUR'); + }); + + it('lets the query currency take over once it moves off the one the pick was made on', async () => { + // Given EUR picked on a query that carried no currency + setSearchQuery('type:expense'); + mockSelectedTransactions.current = {}; + const {rerender} = render(); + await waitForBatchedUpdates(); + await act(async () => { + mockCapturedFooterProps.current?.onCurrencyChange?.(CONST.CURRENCY.EUR); + await waitForBatchedUpdates(); + }); + + // When a saved search on the same hash opens with GBP, which no footer change made + jest.mocked(getFooterConvertedAmounts).mockClear(); + setSearchQuery(`type:expense footerCurrency:${CONST.CURRENCY.GBP}`); + rerender(); + await waitForBatchedUpdates(); + + // Then the footer converts to the query's GBP rather than holding on to the earlier pick + expect(getFooterConvertedAmounts).toHaveBeenCalledWith(expect.objectContaining({targetCurrency: CONST.CURRENCY.GBP})); + expect(getFooterConvertedAmounts).not.toHaveBeenCalledWith(expect.objectContaining({targetCurrency: CONST.CURRENCY.EUR})); + }); + + it('starts from the currency the query carries, so a saved search reopens on it', async () => { + setSearchQuery('type:expense footerCurrency:EUR'); + mockSelectedTransactions.current = {}; + + render(); + await waitForBatchedUpdates(); + + expect(getFooterConvertedAmounts).toHaveBeenCalledWith(expect.objectContaining({targetCurrency: CONST.CURRENCY.EUR})); + }); + }); + + describe('count selector', () => { + it('defaults an expense-report search to the report count', async () => { + setSearchQuery('type:expense-report'); + mockSelectedTransactions.current = {}; + + render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current).toEqual( + expect.objectContaining({count: 87, countType: CONST.SEARCH.FOOTER_COUNT.REPORTS, defaultCountType: CONST.SEARCH.FOOTER_COUNT.REPORTS}), + ); + }); + + it('defaults an expense search to the expense count', async () => { + mockSelectedTransactions.current = {}; + + render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current).toEqual( + expect.objectContaining({count: 1204, countType: CONST.SEARCH.FOOTER_COUNT.EXPENSES, defaultCountType: CONST.SEARCH.FOOTER_COUNT.EXPENSES}), + ); + }); + + it('shows the expense count on an expense-report search once the query selects it, without a new search', async () => { + setSearchQuery('type:expense-report footerCount:expenses'); + mockSelectedTransactions.current = {}; + + render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 1204, countType: CONST.SEARCH.FOOTER_COUNT.EXPENSES})); + }); + + it('offers the count selector once every matching item is selected, since the footer is back on the whole search', async () => { + setSearchQuery('type:expense-report'); + // Select-all keeps the loaded rows selected while the footer shows the server's whole-search figures. + mockSelectedTransactions.current = {transaction1: buildSelectedTransaction(CONST.CURRENCY.USD)}; + mockAreAllMatchingItemsSelected.current = true; + + render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 87, countType: CONST.SEARCH.FOOTER_COUNT.REPORTS})); + }); + + it('keeps the count selector on a hand-picked selection, counting the reports those rows sit on', async () => { + setSearchQuery('type:expense footerCount:reports'); + // Two expenses from one report, one from another: three expenses, two reports. + mockSelectedTransactions.current = { + transaction1: {...buildSelectedTransaction(CONST.CURRENCY.USD), reportID: 'report1'}, + transaction2: {...buildSelectedTransaction(CONST.CURRENCY.USD), reportID: 'report1'}, + transaction3: {...buildSelectedTransaction(CONST.CURRENCY.USD), reportID: 'report2'}, + }; + + render(); + await waitForBatchedUpdates(); + + // The server's 87 describes the whole search, so the selection reports its own two. + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.REPORTS, count: 2})); + }); + + it('counts no report for a selected unreported expense, which sits on none', async () => { + setSearchQuery('type:expense footerCount:reports'); + // Two expenses from one report, one unreported: three expenses, one report. + mockSelectedTransactions.current = { + transaction1: {...buildSelectedTransaction(CONST.CURRENCY.USD), reportID: 'report1'}, + transaction2: {...buildSelectedTransaction(CONST.CURRENCY.USD), reportID: 'report1'}, + transaction3: {...buildSelectedTransaction(CONST.CURRENCY.USD), reportID: CONST.REPORT.UNREPORTED_REPORT_ID}, + }; + + render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.REPORTS, count: 1})); + }); + + it('counts the selected expenses when the count selector is on expenses', async () => { + setSearchQuery('type:expense footerCount:expenses'); + mockSelectedTransactions.current = { + transaction1: {...buildSelectedTransaction(CONST.CURRENCY.USD), reportID: 'report1'}, + transaction2: {...buildSelectedTransaction(CONST.CURRENCY.USD), reportID: 'report1'}, + }; + + render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.EXPENSES, count: 2})); + }); + + it('offers the total selector but not the count on a grouped search with nothing selected', async () => { + setSearchQuery('type:expense groupBy:category'); + mockSelectedTransactions.current = {}; + + // A grouped search counts expenses only, so the server sends no report count to switch to. + render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current?.countType).toBeUndefined(); + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({totalType: CONST.SEARCH.FOOTER_TOTAL.TOTAL, count: 1204, total: 36000})); + }); + + it('offers both selectors on a grouped search once individual expenses are selected', async () => { + setSearchQuery('type:expense groupBy:category footerTotal:reimbursable'); + // Expanded rows are stored as transactions, not as the group, so their own flags are available. + mockSelectedTransactions.current = { + reimbursable1: buildFlaggedTransaction(100, {reimbursable: true}), + nonReimbursable1: buildFlaggedTransaction(300, {reimbursable: false}), + }; + + render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current).toEqual( + expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.EXPENSES, totalType: CONST.SEARCH.FOOTER_TOTAL.REIMBURSABLE, count: 2, total: -100}), + ); + }); + + it('keeps both selectors on the Reports tab, where a selected report row is the search unit', async () => { + setSearchQuery('type:expense-report'); + // Selecting a report stores its expenses, flagged as selected via the row, which is the same flag a grouped + // search uses for a group. On a Reports search those expenses are exactly what the footer should describe. + mockSelectedTransactions.current = { + transaction1: {...buildFlaggedTransaction(100, {reimbursable: true}), groupKey: 'report1', isSelectedViaGroup: true}, + transaction2: {...buildFlaggedTransaction(300, {reimbursable: false}), groupKey: 'report1', isSelectedViaGroup: true}, + }; + + render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.REPORTS, totalType: CONST.SEARCH.FOOTER_TOTAL.TOTAL, total: -400})); + }); + + it('offers neither selector while a group with no loaded expenses is selected', async () => { + setSearchQuery('type:expense groupBy:category'); + mockSelectedTransactions.current = {[`${CONST.SEARCH.GROUP_PREFIX}category1`]: buildSelectedTransaction(CONST.CURRENCY.USD, CONST.CURRENCY.USD, -100)}; + + render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current?.countType).toBeUndefined(); + expect(mockCapturedFooterProps.current?.totalType).toBeUndefined(); + }); + + it('names the breakdown the figure is, keeping the selector so an excluded group can lead back to the plain total', async () => { + // Given a select-all on a billable total, with one group taken off it + setSearchQuery('type:expense groupBy:category footerTotal:billable'); + mockSelectedTransactions.current = {}; + mockExcludedTransactions.current = {[`${CONST.SEARCH.GROUP_PREFIX}category1`]: buildSelectedTransaction(CONST.CURRENCY.USD, CONST.CURRENCY.USD, -100)}; + mockAreAllMatchingItemsSelected.current = true; + + render(); + await waitForBatchedUpdates(); + + // Then the label names the billable breakdown, and the selector stays: a whole group has no breakdown of its own + // yet, so the figure is hidden and the plain total is the way back to one. + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({totalType: CONST.SEARCH.FOOTER_TOTAL.BILLABLE, shouldShowTotalSelector: true, countType: undefined})); + }); + + it('keeps the plain total over a group selection the client sums itself, which has no breakdown to apply', async () => { + setSearchQuery('type:expense groupBy:category footerTotal:billable'); + mockSelectedTransactions.current = {[`${CONST.SEARCH.GROUP_PREFIX}category1`]: buildSelectedTransaction(CONST.CURRENCY.USD, CONST.CURRENCY.USD, -100)}; + + render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({totalType: undefined, shouldShowTotalSelector: false})); + }); + + it('offers neither selector while a group row is selected through its header, which stores its expenses', async () => { + setSearchQuery('type:expense groupBy:category'); + // Ticking a group whose expenses are loaded stores them individually, each flagged as selected via the group. + mockSelectedTransactions.current = { + transaction1: {...buildFlaggedTransaction(100, {reimbursable: true}), groupKey: `${CONST.SEARCH.GROUP_PREFIX}category1`, isSelectedViaGroup: true}, + transaction2: {...buildFlaggedTransaction(300, {reimbursable: false}), groupKey: `${CONST.SEARCH.GROUP_PREFIX}category1`, isSelectedViaGroup: true}, + }; + + render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current?.countType).toBeUndefined(); + expect(mockCapturedFooterProps.current?.totalType).toBeUndefined(); + }); + + it('brings both selectors back once a group is part-deselected, which clears the group flag', async () => { + setSearchQuery('type:expense groupBy:category'); + mockSelectedTransactions.current = { + transaction1: {...buildFlaggedTransaction(100, {reimbursable: true}), groupKey: `${CONST.SEARCH.GROUP_PREFIX}category1`, isSelectedViaGroup: false}, + }; + + render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.EXPENSES, totalType: CONST.SEARCH.FOOTER_TOTAL.TOTAL})); + }); + + it('keeps the server report count on a select-all with exclusions, which the selection cannot recount', async () => { + setSearchQuery('type:expense footerCount:reports'); + mockSelectedTransactions.current = {}; + mockExcludedTransactions.current = {transaction1: buildSelectedTransaction(CONST.CURRENCY.USD)}; + mockAreAllMatchingItemsSelected.current = true; + + render(); + await waitForBatchedUpdates(); + + // A select-all covers rows that were never loaded, so only the server can say how many reports they span. + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.REPORTS, count: 87})); + }); + + it('writes a count change into the query even over a selection, since it changes no hash and has to stick', async () => { + setSearchQuery('type:expense'); + // Three expenses across two reports, so the two counts can't be confused for each other. + mockSelectedTransactions.current = { + transaction1: {...buildSelectedTransaction(CONST.CURRENCY.USD), reportID: 'report1'}, + transaction2: {...buildSelectedTransaction(CONST.CURRENCY.USD), reportID: 'report1'}, + transaction3: {...buildSelectedTransaction(CONST.CURRENCY.USD), reportID: 'report2'}, + }; + + const {rerender} = render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.EXPENSES, count: 3})); + + await act(async () => { + mockCapturedFooterProps.current?.onCountChange?.(CONST.SEARCH.FOOTER_COUNT.REPORTS); + await waitForBatchedUpdates(); + }); + + // The query carries the choice, which is what makes it survive leaving and returning to the tab... + const nextQuery = mockSetParams.mock.calls.at(0)?.at(0)?.q; + expect(nextQuery).toContain('footerCount:reports'); + + // Replaying the query the app would now be on: the footer counts the two reports those expenses sit on. + setSearchQuery(nextQuery ?? ''); + rerender(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.REPORTS, count: 2})); + }); + + it('applies a count change by writing it into the query, which leaves the search hash alone', async () => { + // A real parsed query, since applying the change rebuilds the query string from it. + setSearchQuery('type:expense'); + mockSelectedTransactions.current = {}; + + render(); + await waitForBatchedUpdates(); + + await act(async () => { + mockCapturedFooterProps.current?.onCountChange?.(CONST.SEARCH.FOOTER_COUNT.REPORTS); + await waitForBatchedUpdates(); + }); + + expect(mockSetParams).toHaveBeenCalledTimes(1); + expect(mockSetParams.mock.calls.at(0)?.at(0)?.q).toContain('footerCount:reports'); + }); + + it('offers no count selector when the search returned no report count', async () => { + mockSelectedTransactions.current = {}; + + render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current?.countType).toBeUndefined(); + expect(mockCapturedFooterProps.current?.count).toBe(1204); + }); + + describe('beta disabled', () => { + beforeEach(() => { + mockIsFooterSelectorsBetaEnabled.current = false; + }); + + it('offers neither selector and leaves the query alone', async () => { + mockSelectedTransactions.current = {}; + setSearchQuery('type:expense footerCount:reports footerTotal:reimbursable footerCurrency:eur'); + + render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current?.countType).toBeUndefined(); + expect(mockCapturedFooterProps.current?.totalType).toBeUndefined(); + // The query's selections are ignored, so the footer shows the whole-search expense count and total. + expect(mockCapturedFooterProps.current?.count).toBe(1204); + expect(mockCapturedFooterProps.current?.currency).toBe(CONST.CURRENCY.USD); + }); + + it('keeps a currency change in local state instead of writing it into the query', async () => { + mockSelectedTransactions.current = {}; + + render(); + await waitForBatchedUpdates(); + + await act(async () => { + mockCapturedFooterProps.current?.onCurrencyChange?.(CONST.CURRENCY.EUR); + await waitForBatchedUpdates(); + }); + + expect(mockSetParams).not.toHaveBeenCalled(); + }); + }); + }); + describe('totals of a grouped search with a cash back row', () => { const SETTLEMENT_A_KEY = `${CONST.SEARCH.GROUP_PREFIX}settlementA` as const; const SETTLEMENT_B_KEY = `${CONST.SEARCH.GROUP_PREFIX}settlementB` as const; const CASH_BACK_KEY = `${CONST.SEARCH.GROUP_PREFIX}cashBack` as const; const renderWithSelection = async (selectedTransactions: SelectedTransactions, {serverTotal = 47500, hasMoreResults = false} = {}) => { - mockSearchQueryContext.current = { - currentSearchHash: 1, - currentSearchKey: undefined, - currentSearchQueryJSON: {hash: 1, type: CONST.SEARCH.DATA_TYPES.EXPENSE, groupBy: CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID}, - }; + setSearchQuery(`type:expense group-by:${CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID}`); // The server count only sums expenses (5) while its total nets the credit: 300 + 200 - 25. - const searchResults = buildSearchResults(CONST.CURRENCY.USD, 5, serverTotal, CONST.SEARCH.DATA_TYPES.EXPENSE, hasMoreResults); + const searchResults = buildSearchResults(CONST.CURRENCY.USD, 5, serverTotal, CONST.SEARCH.DATA_TYPES.EXPENSE, undefined, hasMoreResults); mockCurrentSearchResults.current = { ...searchResults, data: { @@ -441,11 +1454,7 @@ describe('SearchSelectionFooter', () => { const SETTLEMENT_KEY = `${CONST.SEARCH.GROUP_PREFIX}settlement` as const; const renderGroupedSearch = async () => { - mockSearchQueryContext.current = { - currentSearchHash: 1, - currentSearchKey: undefined, - currentSearchQueryJSON: {hash: 1, type: CONST.SEARCH.DATA_TYPES.EXPENSE, groupBy: CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID}, - }; + setSearchQuery(`type:expense group-by:${CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID}`); const searchResults = buildSearchResults(undefined, 2); mockCurrentSearchResults.current = { ...searchResults, diff --git a/tests/unit/Search/searchFooterBreakdownRefireTest.ts b/tests/unit/Search/searchFooterBreakdownRefireTest.ts new file mode 100644 index 000000000000..5761e469fab5 --- /dev/null +++ b/tests/unit/Search/searchFooterBreakdownRefireTest.ts @@ -0,0 +1,124 @@ +import {search} from '@libs/actions/Search'; +import {makeRequestWithSideEffects, waitForWrites} from '@libs/API'; +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; + +import CONST from '@src/CONST'; + +jest.mock('@libs/API', () => ({ + makeRequestWithSideEffects: jest.fn(), + waitForWrites: jest.fn(), + write: jest.fn(), + read: jest.fn(), +})); + +const mockedMakeRequestWithSideEffects = jest.mocked(makeRequestWithSideEffects); +const mockedWaitForWrites = jest.mocked(waitForWrites); + +function getQueryJSON(query: string) { + const queryJSON = buildSearchQueryJSON(query); + if (!queryJSON) { + throw new Error('Query JSON should be defined for test setup'); + } + + return queryJSON; +} + +function getLastRequestJsonQuery(): string { + const requestParams = mockedMakeRequestWithSideEffects.mock.calls.at(-1)?.[1]; + if (!requestParams || !('jsonQuery' in requestParams) || typeof requestParams.jsonQuery !== 'string') { + throw new Error('Search request params with jsonQuery should be defined'); + } + + return requestParams.jsonQuery; +} + +/** Holds the next request open until the test resolves it. */ +function holdNextRequest() { + let resolveRequest: () => void = () => {}; + const requestPromise = new Promise<{jsonCode: number}>((resolve) => { + resolveRequest = () => resolve({jsonCode: CONST.JSON_CODE.SUCCESS}); + }); + mockedMakeRequestWithSideEffects.mockImplementationOnce(() => requestPromise); + return resolveRequest; +} + +// The footer breakdown is not part of the search hash, so the hash+offset dedupe has to tell breakdowns apart itself. +describe('search footer breakdown re-fire', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedWaitForWrites.mockResolvedValue(undefined); + mockedMakeRequestWithSideEffects.mockResolvedValue(undefined); + }); + + it('re-fires another breakdown that collides with an in-flight totals request', async () => { + // Given a billable totals request still waiting for its response + const billableQueryJSON = getQueryJSON(`type:expense ${CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_TOTAL}:${CONST.SEARCH.FOOTER_TOTAL.BILLABLE}`); + const nonBillableQueryJSON = getQueryJSON(`type:expense ${CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_TOTAL}:${CONST.SEARCH.FOOTER_TOTAL.NON_BILLABLE}`); + expect(nonBillableQueryJSON.hash).toBe(billableQueryJSON.hash); + const resolveBillableRequest = holdNextRequest(); + search({queryJSON: billableQueryJSON, searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, offset: 0, shouldCalculateTotals: true, isLoading: false, shouldShowLoading: false}); + + // When the user switches to non-billable before it lands, which shares the hash and the offset + let didNonBillableSettle = false; + const nonBillableSearch = Promise.resolve( + search({queryJSON: nonBillableQueryJSON, searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, offset: 0, shouldCalculateTotals: true, isLoading: false, shouldShowLoading: false}), + ).then(() => { + didNonBillableSettle = true; + }); + + // Then it waits behind the in-flight request instead of being dropped, so the footer keeps its skeleton + await Promise.resolve(); + await Promise.resolve(); + expect(mockedMakeRequestWithSideEffects.mock.calls).toHaveLength(1); + expect(didNonBillableSettle).toBe(false); + + // When the billable request finishes + resolveBillableRequest(); + await nonBillableSearch; + + // Then the non-billable breakdown is asked for afterward, so the latest one is what lands on the snapshot + expect(mockedMakeRequestWithSideEffects.mock.calls).toHaveLength(2); + expect(getLastRequestJsonQuery()).toContain(CONST.SEARCH.FOOTER_TOTAL.NON_BILLABLE); + }); + + it('stamps the snapshot with the breakdown its total answers once the response lands', async () => { + // Given a non-billable totals request + const queryJSON = getQueryJSON(`type:expense ${CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_TOTAL}:${CONST.SEARCH.FOOTER_TOTAL.NON_BILLABLE}`); + + // When it is sent + await search({queryJSON, searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, offset: 0, shouldCalculateTotals: true, isLoading: false}); + + // Then its success stamps non-billable on the snapshot, after the response that may replace `snapshot.search` + expect(mockedMakeRequestWithSideEffects.mock.calls.at(-1)?.[2]?.successData).toEqual([ + expect.objectContaining({value: {search: {footerTotal: CONST.SEARCH.FOOTER_TOTAL.NON_BILLABLE}}}), + ]); + }); + + it('leaves the stamp alone for a later page without totals, which keeps the earlier total', async () => { + // Given a later page of a non-billable search, asked for without totals + const queryJSON = getQueryJSON(`type:expense ${CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_TOTAL}:${CONST.SEARCH.FOOTER_TOTAL.NON_BILLABLE}`); + + // When it is sent + await search({queryJSON, searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, offset: 50, shouldCalculateTotals: false, isLoading: false}); + + // Then nothing is stamped, since the total on the snapshot is not one this request answered + expect(mockedMakeRequestWithSideEffects.mock.calls.at(-1)?.[2]?.successData).toEqual([]); + }); + + it('still drops the same breakdown colliding with an in-flight totals request', async () => { + // Given a billable totals request still waiting for its response + const queryJSON = getQueryJSON(`type:expense ${CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_TOTAL}:${CONST.SEARCH.FOOTER_TOTAL.BILLABLE}`); + const resolveRequest = holdNextRequest(); + const firstSearch = search({queryJSON, searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, offset: 0, shouldCalculateTotals: true, isLoading: false}); + + // When the same breakdown is asked for again, like the Search component refetching it + const secondSearch = search({queryJSON, searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, offset: 0, shouldCalculateTotals: true, isLoading: false}); + resolveRequest(); + await firstSearch; + await Promise.resolve(); + + // Then only one request reaches the backend, because the in-flight one already answers it + expect(secondSearch).toBeUndefined(); + expect(mockedMakeRequestWithSideEffects.mock.calls).toHaveLength(1); + }); +}); diff --git a/tests/unit/SearchAutocompleteUtilsTest.ts b/tests/unit/SearchAutocompleteUtilsTest.ts index c22a11bc2d87..c1ca2c0e4de6 100644 --- a/tests/unit/SearchAutocompleteUtilsTest.ts +++ b/tests/unit/SearchAutocompleteUtilsTest.ts @@ -51,6 +51,26 @@ describe('SearchAutocompleteUtils', () => { ]); }); + it('should highlight the Spend footer selections in either spelling', () => { + const input = 'footer-count:reports footerTotal:non-reimbursable footer-currency:EUR'; + + const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList); + + expect(result).toEqual([ + {start: 13, type: 'mention-user', length: 7}, // footer-count:reports + {start: 33, type: 'mention-user', length: 16}, // footerTotal:non-reimbursable + {start: 66, type: 'mention-user', length: 3}, // footer-currency:EUR + ]); + }); + + it('should not highlight a Spend footer selection the footer cannot render', () => { + const input = 'footer-count:pandas footerTotal:whatever footer-currency:NOPE'; + + const result = parseForLiveMarkdown(input, currentUserName, mockSubstitutionMap, mockUserLogins, mockCurrencyList, mockCategoryList, mockTagList, mockExportedToList); + + expect(result).toEqual([]); + }); + it('should highlight new PURCHASE_CURRENCY filter', () => { const input = 'purchaseCurrency:USD'; diff --git a/tests/unit/SearchParserTest.ts b/tests/unit/SearchParserTest.ts index fed36462ff0c..6602508e4cdd 100644 --- a/tests/unit/SearchParserTest.ts +++ b/tests/unit/SearchParserTest.ts @@ -1612,6 +1612,71 @@ const limitTests = [ }, ]; +// The footer's selections parse as filters, since that is where the backend reads them from. +const footerSelectionTests = [ + { + description: 'every footer selection in its canonical spelling', + query: 'type:expense-report footerCount:reports footerTotal:non-reimbursable', + expected: { + type: 'expense-report', + sortBy: 'date', + sortOrder: 'desc', + view: 'table', + filters: { + operator: 'and', + left: { + operator: 'eq', + left: 'footerCount', + right: 'reports', + }, + right: { + operator: 'eq', + left: 'footerTotal', + right: 'non-reimbursable', + }, + }, + }, + }, + { + description: 'every footer selection in its user-friendly spelling, alongside a filter', + query: 'type:expense footer-currency:USD merchant:Amazon', + expected: { + type: 'expense', + sortBy: 'date', + sortOrder: 'desc', + view: 'table', + filters: { + operator: 'and', + left: { + operator: 'eq', + left: 'footerCurrency', + right: 'USD', + }, + right: { + operator: 'eq', + left: 'merchant', + right: 'Amazon', + }, + }, + }, + }, + { + description: 'footer selections are case-insensitive', + query: 'type:expense FOOTER-TOTAL:reimbursable', + expected: { + type: 'expense', + sortBy: 'date', + sortOrder: 'desc', + view: 'table', + filters: { + operator: 'eq', + left: 'footerTotal', + right: 'reimbursable', + }, + }, + }, +]; + const compareTests = [ { // Given the previous-period compare mode @@ -1734,6 +1799,12 @@ describe('search parser - limit filter', () => { }); }); +describe('search parser - Spend footer selections', () => { + test.each(footerSelectionTests)('$description: $query', ({query, expected}) => { + expect(parseSearchQueryWithoutRawFilters(query)).toEqual(expected); + }); +}); + describe('search parser - compare root key', () => { test.each(compareTests)('$description: $query', ({query, expected}) => { expect(parseSearchQueryWithoutRawFilters(query)).toEqual(expected); diff --git a/tests/unit/useSearchPageSetupTest.tsx b/tests/unit/useSearchPageSetupTest.tsx index c77bfa55d010..4f763a79e483 100644 --- a/tests/unit/useSearchPageSetupTest.tsx +++ b/tests/unit/useSearchPageSetupTest.tsx @@ -10,6 +10,7 @@ import type SearchResults from '@src/types/onyx/SearchResults'; import type * as ReactNavigation from '@react-navigation/native'; +const mockClearSelectedTransactions = jest.fn(); const mockOpenSearch = jest.fn(); const mockSearch = jest.fn(); const mockMarkPageRequestedSearch = jest.fn(); @@ -58,7 +59,7 @@ jest.mock('@hooks/useSearchShouldCalculateTotals', () => () => false); jest.mock('@components/Search/SearchContext', () => ({ useSearchResultsContext: () => ({shouldUseLiveData: false, currentSearchResults: mockSearchResults}), useSearchQueryContext: () => ({currentSearchKey: mockSearchKey}), - useSearchSelectionActions: () => ({clearSelectedTransactions: jest.fn()}), + useSearchSelectionActions: () => ({clearSelectedTransactions: mockClearSelectedTransactions}), useSearchSelectionContext: () => ({areAllMatchingItemsSelected: false}), })); @@ -112,6 +113,7 @@ function getClearedHashes() { describe('useSearchPageSetup', () => { beforeEach(() => { + mockClearSelectedTransactions.mockClear(); mockOpenSearch.mockClear(); mockSearch.mockClear(); mockSearchResults = undefined; @@ -183,6 +185,15 @@ describe('useSearchPageSetup', () => { expect(getClearedHashes()).toEqual([queryJSON?.hash]); }); + it('clears the selection when the search itself changes', () => { + const {rerender} = renderHook(({queryJSON: currentQueryJSON}) => useSearchPageSetup(currentQueryJSON), {initialProps: {queryJSON}}); + mockClearSelectedTransactions.mockClear(); + + rerender({queryJSON: queryJSONB}); + + expect(mockClearSelectedTransactions).toHaveBeenCalledWith(queryJSONB?.hash); + }); + it('does not clear the same hash twice when the failure comes straight back', () => { // Given an inherited error that has just been cleared mockSearchResults = buildErroredSnapshot(queryJSON?.hash ?? 0);