Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 4 additions & 9 deletions src/components/Search/FilterDropdowns/SearchFooterPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';

import {getFooterTotalItems} from '@libs/SearchUIUtils';
import {getFooterCountItems, getFooterTotalItems} from '@libs/SearchUIUtils';

import CONST from '@src/CONST';

Expand Down Expand Up @@ -84,10 +84,7 @@ function SearchFooterPopup({
const styles = useThemeStyles();
const [selectedFooterFilter, setSelectedFooterFilter] = useState<SelectedFooterFilter>(null);

const countItems: Array<SingleSelectItem<SearchFooterCount>> = [
{text: translate('common.expenses'), value: CONST.SEARCH.FOOTER_COUNT.EXPENSES},
{text: translate('common.reports'), value: CONST.SEARCH.FOOTER_COUNT.REPORTS},
];
const countItems: Array<SingleSelectItem<SearchFooterCount>> = getFooterCountItems(translate);
const totalItems = getFooterTotalItems(translate);

if (!selectedFooterFilter) {
Expand All @@ -104,9 +101,7 @@ function SearchFooterPopup({
)}
{shouldShowTotalSelector && !!totalType && (
<MenuItemWithTopDescription
shouldShowRightIcon
// Both rows re-run the search, so they are inert until the one in flight lands. The count row
// stays interactive, since switching it needs no request.
shouldShowRightIcon={!isTotalLoading}
interactive={!isTotalLoading}
description={translate('common.total')}
title={totalItems.find((item) => item.value === totalType)?.text}
Expand All @@ -115,7 +110,7 @@ function SearchFooterPopup({
/>
)}
<MenuItemWithTopDescription
shouldShowRightIcon
shouldShowRightIcon={!isTotalLoading && !isOffline}
interactive={!isTotalLoading && !isOffline}
description={translate('common.currency')}
title={currency ?? defaultCurrency}
Expand Down
4 changes: 2 additions & 2 deletions src/components/Search/SearchPageFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';

import {getFooterTotalItems} from '@libs/SearchUIUtils';
import {getFooterCountItems, getFooterTotalItems} from '@libs/SearchUIUtils';

import CONST from '@src/CONST';

Expand Down Expand Up @@ -227,7 +227,7 @@ function SearchPageFooter({
/>
);

const countLabel = translate(countType === CONST.SEARCH.FOOTER_COUNT.REPORTS ? 'common.reports' : 'common.expenses');
const countLabel = getFooterCountItems(translate).find((item) => item.value === (countType ?? CONST.SEARCH.FOOTER_COUNT.EXPENSES))?.text;

// 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.
Expand Down
27 changes: 20 additions & 7 deletions src/components/Search/SearchSelectionFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ type FooterCurrencyState = {

/** The query's currency when the pick was made. Once the query moves off it, the query's own currency wins */
queryCurrency: string | undefined;

/** The currency the pick wrote into the query. The default is left out, so a Reset writes none */
writtenQueryCurrency: string | undefined;
};

const EMPTY_FOOTER_SELECTION = {footerCount: undefined, footerTotal: undefined, footerCurrency: undefined};
Expand Down Expand Up @@ -171,6 +174,7 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp
selectedCurrency: undefined,
defaultCurrency: undefined,
queryCurrency: undefined,
writtenQueryCurrency: 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.
Expand All @@ -183,9 +187,10 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp
const pendingReportConversionSources = useRef<Record<string, Record<string, number>>>({});
const isCurrentFooterState = footerCurrencyState.searchHash === currentSearchHash;
// 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;
// from. This session's own pick holds while the query is on the currency it was made on or the one it wrote. Once
// the query moves anywhere else (an edit, or a saved search on the same hash), the query wins.
const isPickAheadOfQuery =
isCurrentFooterState && (footerCurrencyState.queryCurrency === footerSelection.footerCurrency || footerCurrencyState.writtenQueryCurrency === footerSelection.footerCurrency);
const selectedCurrency = (isPickAheadOfQuery ? footerCurrencyState.selectedCurrency : undefined) ?? footerSelection.footerCurrency;
const defaultFooterCurrency = isCurrentFooterState ? footerCurrencyState.defaultCurrency : undefined;

Expand Down Expand Up @@ -667,13 +672,15 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp
};

const handleFooterCurrencyChange = (currency: string) => {
const writtenQueryCurrency = currency === searchTargetCurrency ? undefined : currency;
setFooterCurrencyState({
searchHash: currentSearchHash,
selectedCurrency: currency,
defaultCurrency: effectiveDefaultCurrency,
queryCurrency: footerSelection.footerCurrency,
writtenQueryCurrency,
});
applyFooterSelection({footerCurrency: currency});
applyFooterSelection({footerCurrency: writtenQueryCurrency});
};

const handleFooterTotalChange = (nextTotalType: SearchFooterTotal) => {
Expand Down Expand Up @@ -851,10 +858,16 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp

// 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.
// An unreported expense sits on no report — it carries the unreported placeholder ID — so it adds none. Neither does
// an IOU report, which the server's count leaves out, so the count holds once the server's figures take over.
const isIOUReportID = (reportID: string) => currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.type === CONST.REPORT.TYPE.IOU;
const selectedReportCount = isReportsSearch
? selectedReports.length
: new Set(selectedTransactionsKeys.map((key) => selectedTransactions[key]?.reportID).filter((reportID) => !!reportID && reportID !== CONST.REPORT.UNREPORTED_REPORT_ID)).size;
? selectedReports.filter((report) => report.type !== CONST.REPORT.TYPE.IOU).length
: new Set(
selectedTransactionsKeys
.map((key) => selectedTransactions[key]?.reportID)
.filter((reportID): reportID is string => !!reportID && reportID !== CONST.REPORT.UNREPORTED_REPORT_ID && !isIOUReportID(reportID)),
).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.
Expand Down
13 changes: 11 additions & 2 deletions src/libs/SearchQueryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1221,7 +1221,14 @@ const NON_FILTER_CHIP_KEYS = new Set<SearchFilterKey>([
CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_TOTAL,
CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_CURRENCY,
]);
const NON_SAVABLE_FILTER_KEYS = new Set<SearchFilterKey>([CONST.SEARCH.SYNTAX_FILTER_KEYS.GROUP_CURRENCY]);
// Keys whose change alone doesn't offer Save. The Spend footer's selections are still saved with a search, but changing
// them is undone from the footer's own Reset, so they offer neither Save nor Reset in the header.
const NON_SAVABLE_FILTER_KEYS = new Set<SearchFilterKey>([
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,
]);

function buildQueryStringWithResetFilters(currentQueryJSON: SearchQueryJSON, defaultQueryJSON: SearchQueryJSON | undefined) {
const resetFilters = (defaultQueryJSON?.flatFilters ?? []).filter((filter) => !NON_FILTER_CHIP_KEYS.has(filter.key));
Expand Down Expand Up @@ -3136,7 +3143,9 @@ function doesQueryMatchDefaultFilterKeysAndType(queryJSON: SearchQueryJSON | und
}

const queryFilterKeys = new Set(queryJSON.flatFilters.map((filter) => filter.key));
const defaultQueryFilterKeys = new Set(defaultQueryJSON.flatFilters.map((filter) => filter.key));
// The Spend footer's keys pick what the footer shows, not which rows match, and a default choice is left out of the
// query. So they never decide whether a last query still belongs to its default one.
const defaultQueryFilterKeys = new Set(defaultQueryJSON.flatFilters.map((filter) => filter.key).filter((key) => !FOOTER_FILTER_KEYS.has(key)));

return [...defaultQueryFilterKeys].every((value) => queryFilterKeys.has(value)) && queryJSON.type === defaultQueryJSON.type;
}
Expand Down
10 changes: 10 additions & 0 deletions src/libs/SearchUIUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import type {
SearchDateFilterKeys,
SearchDatePreset,
SearchFilterKey,
SearchFooterCount,
SearchFooterTotal,
SearchGroupBy,
SearchPaidStatus,
Expand Down Expand Up @@ -5405,6 +5406,14 @@ function getViewOptions(translate: LocalizedTranslate) {
return Object.values(CONST.SEARCH.VIEW).map<SingleSelectItem<SearchView>>((value) => ({text: translate(`search.view.${value}`), value}));
}

/** The options the Spend footer's count selector offers, in display order. */
function getFooterCountItems(translate: LocalizedTranslate) {
return [
{text: translate('common.expenses'), value: CONST.SEARCH.FOOTER_COUNT.EXPENSES},
{text: translate('common.reports'), value: CONST.SEARCH.FOOTER_COUNT.REPORTS},
] satisfies Array<SingleSelectItem<SearchFooterCount>>;
}

/**
* 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.
Expand Down Expand Up @@ -7479,6 +7488,7 @@ export {
getSortOrderOptions,
getGroupBySections,
getViewOptions,
getFooterCountItems,
getFooterTotalItems,
getCurrencyOptions,
getFeedOptions,
Expand Down
18 changes: 17 additions & 1 deletion src/pages/Search/SearchSavePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {saveSearch} from '@libs/actions/Search';
import Navigation from '@libs/Navigation/Navigation';
import {rand64} from '@libs/NumberUtils';
import {savedSearchIDToSearchKey} from '@libs/SearchKeyUtils';
import {getCustomColumnDefault, getSearchColumnTranslationKey, getValidGroupBy, mapFiltersFormToLabelValueList} from '@libs/SearchUIUtils';
import {getCustomColumnDefault, getFooterCountItems, getFooterTotalItems, getSearchColumnTranslationKey, getValidGroupBy, mapFiltersFormToLabelValueList} from '@libs/SearchUIUtils';
import type {SearchFilter} from '@libs/SearchUIUtils';
import {getFieldRequiredErrors} from '@libs/ValidationUtils';

Expand Down Expand Up @@ -136,6 +136,22 @@ function getAppliedDisplays(searchAdvancedFiltersForm: Partial<SearchAdvancedFil
appliedDisplays.push({label: translate('search.view.label'), value: translate(`search.view.${searchAdvancedFiltersForm.view}`)});
}

// The Spend footer's choices summarize the results rather than narrow them, so they sit with the display settings.
// The query leaves defaults out, so only a choice the user made shows here.
if (searchAdvancedFiltersForm.footerCount) {
Comment thread
truph01 marked this conversation as resolved.
Resolved
const footerCount = searchAdvancedFiltersForm.footerCount;
appliedDisplays.push({label: translate('common.count'), value: getFooterCountItems(translate).find((item) => item.value === footerCount)?.text ?? footerCount});
}

if (searchAdvancedFiltersForm.footerTotal) {
const footerTotal = searchAdvancedFiltersForm.footerTotal;
appliedDisplays.push({label: translate('common.total'), value: getFooterTotalItems(translate).find((item) => item.value === footerTotal)?.text ?? footerTotal});
}

if (searchAdvancedFiltersForm.footerCurrency) {
appliedDisplays.push({label: translate('common.currency'), value: searchAdvancedFiltersForm.footerCurrency});
}

if (queryJSON?.sortBy) {
appliedDisplays.push({label: translate('search.display.sortBy'), value: translate(getSearchColumnTranslationKey(queryJSON.sortBy, queryJSON.type))});
}
Expand Down
58 changes: 58 additions & 0 deletions tests/unit/Search/SearchQueryUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3145,6 +3145,28 @@ describe('SearchQueryUtils', () => {
expect(isSearchQuerySavable(undefined, defaultQueryJSON)).toBe(false);
});

it('returns false when only a Spend footer selection differs from the default query', () => {
// Given a saved search on the reimbursable total
const defaultQueryJSON = buildSearchQueryJSON('type:expense category:travel footerTotal:reimbursable');

// When only the footer selections change, which the footer's own Reset undoes
const currentQueryJSON = buildSearchQueryJSON('type:expense category:travel footerTotal:billable footerCount:reports footerCurrency:EUR');

// Then the header doesn't offer Save
expect(isSearchQuerySavable(currentQueryJSON, defaultQueryJSON)).toBe(false);
});

it('still returns true when a row filter changes alongside a footer selection', () => {
// Given a search on one category
const defaultQueryJSON = buildSearchQueryJSON('type:expense category:travel');

// When the category changes along with the footer total
const currentQueryJSON = buildSearchQueryJSON('type:expense category:meals footerTotal:billable');

// Then the header offers Save, since a row filter changed
expect(isSearchQuerySavable(currentQueryJSON, defaultQueryJSON)).toBe(true);
});

it('returns false when the query equals the default query', () => {
// Given a query that matches its default
const defaultQueryJSON = buildSearchQueryJSON('type:expense category:travel');
Expand Down Expand Up @@ -5991,6 +6013,42 @@ describe('SearchQueryUtils', () => {
const lastQuery = 'type:invoice status:all';
expect(getValidLastQuery(lastQuery, defaultSearchQuery)).toBe(defaultSearchQuery);
});

describe('with the Spend footer selections', () => {
it('keeps a last query switched back to the default total, which leaves the total out', () => {
// Given a saved search on the reimbursable total
const savedQuery = 'type:expense status:all footerTotal:reimbursable';

// When it is switched back to the plain total, which leaves the total out of the last query
const lastQuery = 'type:expense status:all';

// Then the last choice comes back, the same as when switching to any other total
expect(getValidLastQuery(lastQuery, savedQuery)).toBe(lastQuery);
expect(getValidLastQuery('type:expense status:all footerTotal:non-reimbursable', savedQuery)).toBe('type:expense status:all footerTotal:non-reimbursable');
});

it('keeps a last query switched back to the default count, which leaves the count out', () => {
// Given a saved search on the report count
const savedQuery = 'type:expense status:all footerCount:reports';

// When it is switched back to the expense count, which leaves the count out of the last query
const lastQuery = 'type:expense status:all';

// Then the last choice comes back
expect(getValidLastQuery(lastQuery, savedQuery)).toBe(lastQuery);
});

it('still drops a last query that lost a real filter', () => {
// Given a saved search with a footer total
const savedQuery = 'type:expense status:all footerTotal:reimbursable';

// When its last query drops the status filter
const lastQuery = 'type:expense footerTotal:reimbursable';

// Then the saved query comes back, since a row filter still decides
expect(getValidLastQuery(lastQuery, savedQuery)).toBe(savedQuery);
});
});
});

describe('doesQueryMatchDefaultFilterKeysAndType', () => {
Expand Down
Loading
Loading