From 6afbc114624d20ee1a93e6870c75b86d46e8f3fc Mon Sep 17 00:00:00 2001 From: truph01 Date: Wed, 9 Sep 2026 10:20:02 +0700 Subject: [PATCH 01/86] feat: add footerCount, footerTotal and footerCurrency query keys for the Spend footer --- src/CONST/index.ts | 19 ++ src/components/Search/types.ts | 13 ++ src/libs/SearchParser/autocompleteParser.js | 113 ++++++++++- .../SearchParser/autocompleteParser.peggy | 5 +- src/libs/SearchParser/baseRules.peggy | 5 + src/libs/SearchParser/searchParser.js | 113 ++++++++++- src/libs/SearchParser/searchParser.peggy | 4 +- src/libs/SearchQueryUtils.ts | 71 ++++++- src/types/form/SearchAdvancedFiltersForm.ts | 16 ++ src/types/onyx/SearchResults.ts | 12 ++ tests/unit/Search/SearchQueryUtilsTest.ts | 189 ++++++++++++++++++ tests/unit/SearchParserTest.ts | 54 +++++ 12 files changed, 607 insertions(+), 7 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index cd0ffae8c643..ea7a50d3cab1 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7612,6 +7612,9 @@ const CONST = { GROUP_BY: 'groupBy', COLUMNS: 'columns', LIMIT: 'limit', + FOOTER_COUNT: 'footerCount', + FOOTER_TOTAL: 'footerTotal', + FOOTER_CURRENCY: 'footerCurrency', }, VIEW: { TABLE: 'table', @@ -7619,6 +7622,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', + }, SYNTAX_FILTER_KEYS: { TYPE: 'type', STATUS: 'status', @@ -7757,6 +7773,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', }, get SEARCH_USER_FRIENDLY_VALUES_MAP() { return { diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index a255ff30f41a..61da9308ae7d 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -161,6 +161,8 @@ type TaskSearchStatus = ValueOf; type SingularSearchStatus = ExpenseSearchStatus | ExpenseReportSearchStatus | InvoiceSearchStatus | TripSearchStatus | TaskSearchStatus; type SearchGroupBy = ValueOf; type SearchView = ValueOf; +type SearchFooterCount = ValueOf; +type SearchFooterTotal = ValueOf; // PieChart is not implemented so we exclude it here to prevent TypeScript errors in `SearchChartView.tsx`. type ChartView = Exclude; type TableColumnSize = ValueOf; @@ -353,6 +355,9 @@ type SearchFilterKey = | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.COLUMNS | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.FOOTER_COUNT + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.FOOTER_TOTAL + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.FOOTER_CURRENCY | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW; type UserFriendlyKey = ValueOf; @@ -384,6 +389,12 @@ type SearchQueryAST = { rawFilterList?: RawQueryFilter[]; columns?: SearchCustomColumnIds | SearchCustomColumnIds[]; limit?: number; + /** Which count the Spend footer displays. Both counts come back on every search, so this is applied client-side. */ + footerCount?: SearchFooterCount; + /** Which aggregate the Spend footer displays as its total. The backend computes it, so changing it retriggers the search. */ + footerTotal?: SearchFooterTotal; + /** Currency the Spend footer's total is displayed in. The backend converts to it, so changing it retriggers the search. */ + footerCurrency?: string; }; type SearchQueryJSON = { @@ -520,6 +531,8 @@ export type { TableColumnSize, SearchGroupBy, SearchView, + SearchFooterCount, + SearchFooterTotal, ChartView, SingularSearchStatus, SearchDatePreset, diff --git a/src/libs/SearchParser/autocompleteParser.js b/src/libs/SearchParser/autocompleteParser.js index 61d1cdd36444..3f4ccc147fdc 100644 --- a/src/libs/SearchParser/autocompleteParser.js +++ b/src/libs/SearchParser/autocompleteParser.js @@ -306,6 +306,12 @@ function peg$parse(input, options) { var peg$c120 = "\u201C"; var peg$c121 = "\u201D"; var peg$c122 = "\""; + var peg$c123 = "footercount"; + var peg$c124 = "footer-count"; + var peg$c125 = "footercurrency"; + var peg$c126 = "footer-currency"; + var peg$c127 = "footertotal"; + var peg$c128 = "footer-total"; var peg$r0 = /^[ \t\r\n\xA0,:=<>!]/; var peg$r1 = /^[:=]/; @@ -465,6 +471,12 @@ function peg$parse(input, options) { var peg$e140 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); var peg$e141 = peg$classExpectation([","], false, false); var peg$e142 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); + var peg$e143 = peg$literalExpectation("footerCount", true); + var peg$e144 = peg$literalExpectation("footer-count", true); + var peg$e145 = peg$literalExpectation("footerCurrency", true); + var peg$e146 = peg$literalExpectation("footer-currency", true); + var peg$e147 = peg$literalExpectation("footerTotal", true); + var peg$e148 = peg$literalExpectation("footer-total", true); var peg$f0 = function(ranges) { return { autocomplete, ranges }; }; var peg$f1 = function(filters) { return filters.filter(Boolean).flat(); }; @@ -655,6 +667,9 @@ function peg$parse(input, options) { var peg$f117 = function(start, inner, end) { return [...start, '"', ...inner, '"'].join(""); }; + var peg$f118 = function() { return "footerCount"; }; + var peg$f119 = function() { return "footerCurrency"; }; + var peg$f120 = function() { return "footerTotal"; }; var peg$currPos = options.peg$currPos | 0; var peg$savedPos = peg$currPos; var peg$posDetailsCache = [{ line: 1, column: 1 }]; @@ -1178,6 +1193,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(); + } + } + } } } } @@ -5012,12 +5036,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$c123) { + peg$currPos += 11; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e143); } + } + if (s1 === peg$FAILED) { + s1 = input.substr(peg$currPos, 12); + if (s1.toLowerCase() === peg$c124) { + peg$currPos += 12; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e144); } + } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f118(); + } + s0 = s1; + + return s0; + } + + function peg$parsefooterCurrency() { + var s0, s1; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 14); + if (s1.toLowerCase() === peg$c125) { + peg$currPos += 14; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e145); } + } + if (s1 === peg$FAILED) { + s1 = input.substr(peg$currPos, 15); + if (s1.toLowerCase() === peg$c126) { + peg$currPos += 15; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e146); } + } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f119(); + } + s0 = s1; + + return s0; + } + + function peg$parsefooterTotal() { + 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$f120(); + } + 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 725be1cedd3a..51b830853892 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" ]); } @@ -133,6 +133,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 19ed053e07a1..291a50d1263d 100644 --- a/src/libs/SearchParser/baseRules.peggy +++ b/src/libs/SearchParser/baseRules.peggy @@ -246,3 +246,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 e39f25116fe3..c720ab2400c1 100644 --- a/src/libs/SearchParser/searchParser.js +++ b/src/libs/SearchParser/searchParser.js @@ -307,6 +307,12 @@ function peg$parse(input, options) { var peg$c120 = "\u201C"; var peg$c121 = "\u201D"; var peg$c122 = "\""; + var peg$c123 = "footercount"; + var peg$c124 = "footer-count"; + var peg$c125 = "footercurrency"; + var peg$c126 = "footer-currency"; + var peg$c127 = "footertotal"; + var peg$c128 = "footer-total"; var peg$r0 = /^[^ \t\r\n\xA0]/; var peg$r1 = /^[ \t\r\n\xA0,:=<>!]/; @@ -469,6 +475,12 @@ function peg$parse(input, options) { var peg$e142 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); var peg$e143 = peg$classExpectation([","], false, false); var peg$e144 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); + var peg$e145 = peg$literalExpectation("footerCount", true); + var peg$e146 = peg$literalExpectation("footer-count", true); + var peg$e147 = peg$literalExpectation("footerCurrency", true); + var peg$e148 = peg$literalExpectation("footer-currency", true); + var peg$e149 = peg$literalExpectation("footerTotal", true); + var peg$e150 = peg$literalExpectation("footer-total", true); var peg$f0 = function(filters) { return applyDefaults(filters); }; var peg$f1 = function(head, tail) { @@ -681,6 +693,9 @@ function peg$parse(input, options) { var peg$f117 = function(start, inner, end) { return [...start, '"', ...inner, '"'].join(""); }; + var peg$f118 = function() { return "footerCount"; }; + var peg$f119 = function() { return "footerCurrency"; }; + var peg$f120 = function() { return "footerTotal"; }; var peg$currPos = options.peg$currPos | 0; var peg$savedPos = peg$currPos; var peg$posDetailsCache = [{ line: 1, column: 1 }]; @@ -1370,6 +1385,15 @@ function peg$parse(input, options) { s1 = peg$parselimit(); if (s1 === peg$FAILED) { s1 = peg$parseview(); + if (s1 === peg$FAILED) { + s1 = peg$parsefooterCount(); + if (s1 === peg$FAILED) { + s1 = peg$parsefooterCurrency(); + if (s1 === peg$FAILED) { + s1 = peg$parsefooterTotal(); + } + } + } } } } @@ -5190,6 +5214,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$c123) { + peg$currPos += 11; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e145); } + } + if (s1 === peg$FAILED) { + s1 = input.substr(peg$currPos, 12); + if (s1.toLowerCase() === peg$c124) { + peg$currPos += 12; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e146); } + } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f118(); + } + s0 = s1; + + return s0; + } + + function peg$parsefooterCurrency() { + var s0, s1; + + s0 = peg$currPos; + s1 = input.substr(peg$currPos, 14); + if (s1.toLowerCase() === peg$c125) { + peg$currPos += 14; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e147); } + } + if (s1 === peg$FAILED) { + s1 = input.substr(peg$currPos, 15); + if (s1.toLowerCase() === peg$c126) { + peg$currPos += 15; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e148); } + } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f119(); + } + s0 = s1; + + return s0; + } + + function peg$parsefooterTotal() { + 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$f120(); + } + s0 = s1; + + return s0; + } + const GROUP_BY_DEFAULT_SORT = { from: "groupFrom", @@ -5233,7 +5344,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" + "type", "keyword", "groupCurrency", "groupBy", "columns", "limit", "view", "footerCount", "footerTotal", "footerCurrency" ]); function isDefaultSortValue(sortBy) { diff --git a/src/libs/SearchParser/searchParser.peggy b/src/libs/SearchParser/searchParser.peggy index d8a0233f34bb..174458690a97 100644 --- a/src/libs/SearchParser/searchParser.peggy +++ b/src/libs/SearchParser/searchParser.peggy @@ -70,7 +70,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" ]); function isDefaultSortValue(sortBy) { @@ -317,7 +317,7 @@ filterKey return k; } -defaultKey "default key" = @(type / sortBy / sortOrder / groupBy / columns / limit / view) +defaultKey "default key" = @(type / sortBy / sortOrder / groupBy / columns / limit / view / footerCount / footerCurrency / footerTotal) identifier = (","+)? parts:(values / quotedString / alphanumeric)|1.., ","+| empty:(","+)? { diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 2861fb35b26a..351caf5ad786 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -108,6 +108,8 @@ 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)); // Create reverse lookup maps for O(1) performance const createKeyToUserFriendlyMap = () => { @@ -664,6 +666,19 @@ function getQueryHashes(query: SearchQueryJSON) { orderedQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_ORDER}:${query.sortOrder}`; orderedQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.COLUMNS}:${Array.isArray(query.columns) ? query.columns.join(',') : query.columns}`; + // The footer's total and currency selections decide which aggregate the backend computes, so each one needs its own + // snapshot and belongs in the primary hash. Both are appended conditionally so queries without them keep the hashes + // (and therefore the saved searches and snapshots) they already have. + // + // footerCount is deliberately absent from every hash: every search already returns both the expense and the report + // count, so switching between them is a client-side change that must not invalidate the snapshot or retrigger a search. + if (query.footerTotal) { + orderedQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.FOOTER_TOTAL}:${query.footerTotal}`; + } + if (query.footerCurrency) { + orderedQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.FOOTER_CURRENCY}:${query.footerCurrency}`; + } + if (query.limit !== undefined) { orderedQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT}:${query.limit}`; } @@ -759,6 +774,19 @@ function getCachedSearchQueryJSON(query: SearchQueryString, rawQuery?: SearchQue result.limit = Number.isInteger(num) && num > 0 ? num : undefined; } + // Same for the footer selections: a hand-typed value the footer can't render is dropped rather than hashed and + // sent to the backend, so it behaves like no selection. The currency is uppercased so `footerCurrency:eur` and + // `footerCurrency:EUR` share one snapshot instead of splitting into two. + if (result.footerCount !== undefined && !VALID_FOOTER_COUNTS.has(result.footerCount)) { + result.footerCount = undefined; + } + if (result.footerTotal !== undefined && !VALID_FOOTER_TOTALS.has(result.footerTotal)) { + result.footerTotal = undefined; + } + if (result.footerCurrency !== undefined) { + result.footerCurrency = typeof result.footerCurrency === 'string' ? result.footerCurrency.toUpperCase() : undefined; + } + const {primaryHash, recentSearchHash, similarSearchHash} = getQueryHashes(result); result.hash = primaryHash; result.recentSearchHash = recentSearchHash; @@ -925,7 +953,7 @@ function buildQueryStringFromFilterFormValues(filterValues: Partial sanitizeSearchValue(value)).join(',')}`); } + // Spend footer display selections. Emitted as root keys so they never reach the filters the backend matches rows on. + if (footerCount) { + filtersString.push(`${CONST.SEARCH.SYNTAX_ROOT_KEYS.FOOTER_COUNT}:${sanitizeSearchValue(footerCount)}`); + } + + if (footerTotal) { + filtersString.push(`${CONST.SEARCH.SYNTAX_ROOT_KEYS.FOOTER_TOTAL}:${sanitizeSearchValue(footerTotal)}`); + } + + if (footerCurrency) { + filtersString.push(`${CONST.SEARCH.SYNTAX_ROOT_KEYS.FOOTER_CURRENCY}:${sanitizeSearchValue(footerCurrency)}`); + } + const mappedFilters = Object.entries(otherFilters) .map(([filterKey, filterValue]) => { const isNegated = filterKey.endsWith(CONST.SEARCH.NOT_MODIFIER); @@ -1688,6 +1729,18 @@ function buildFilterFormValuesFromQuery( filtersForm[FILTER_KEYS.LIMIT] = queryJSON.limit.toString(); } + if (queryJSON.footerCount) { + filtersForm[FILTER_KEYS.FOOTER_COUNT] = queryJSON.footerCount; + } + + if (queryJSON.footerTotal) { + filtersForm[FILTER_KEYS.FOOTER_TOTAL] = queryJSON.footerTotal; + } + + if (queryJSON.footerCurrency) { + filtersForm[FILTER_KEYS.FOOTER_CURRENCY] = queryJSON.footerCurrency; + } + return filtersForm; } @@ -2018,7 +2071,7 @@ function buildUserReadableQueryString({ reportAttributes, bankAccountList, }: BuildUserReadableQueryStringParams) { - const {type, groupBy, view, columns, rawFilterList, flatFilters: filters = [], limit} = queryJSON; + const {type, groupBy, view, columns, rawFilterList, flatFilters: filters = [], limit, footerCount, footerTotal, footerCurrency} = queryJSON; if (rawFilterList && rawFilterList.length > 0) { const segments: string[] = []; @@ -2097,6 +2150,20 @@ function buildUserReadableQueryString({ title += ` columns:${columnValue}`; } + // Rendered so a query edited from the search input keeps the footer selections it was displaying, the same way + // columns survives an edit. Values are already user-friendly, so they need no mapping. + if (footerCount) { + title += ` ${getUserFriendlyKey(CONST.SEARCH.SYNTAX_ROOT_KEYS.FOOTER_COUNT)}:${footerCount}`; + } + + if (footerTotal) { + title += ` ${getUserFriendlyKey(CONST.SEARCH.SYNTAX_ROOT_KEYS.FOOTER_TOTAL)}:${footerTotal}`; + } + + if (footerCurrency) { + title += ` ${getUserFriendlyKey(CONST.SEARCH.SYNTAX_ROOT_KEYS.FOOTER_CURRENCY)}:${footerCurrency}`; + } + for (const filterObject of filters) { const key = filterObject.key; const displayQueryFilters = getDisplayQueryFiltersForKey( diff --git a/src/types/form/SearchAdvancedFiltersForm.ts b/src/types/form/SearchAdvancedFiltersForm.ts index 010091d7e4ef..69fc784c5b92 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, @@ -260,6 +262,10 @@ const FILTER_KEYS = { COLUMNS: 'columns', LIMIT: 'limit', + + FOOTER_COUNT: 'footerCount', + FOOTER_TOTAL: 'footerTotal', + FOOTER_CURRENCY: 'footerCurrency', } as const; const ALLOWED_TYPE_FILTERS: Record> = { @@ -385,6 +391,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, @@ -471,6 +480,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, @@ -908,6 +920,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; } & Record & Record & Record diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index b9b230fa10c4..8aebfe6063b7 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -94,6 +94,18 @@ type SearchResultsInfo = { /** The currency of the total spend */ currency?: string; + /** The total spend of the matching reimbursable expenses. Returned only when the query asks for this aggregate. */ + reimbursableTotal?: number; + + /** The total spend of the matching non-reimbursable expenses. Returned only when the query asks for this aggregate. */ + nonReimbursableTotal?: number; + + /** The total spend of the matching billable expenses. Returned only when the query asks for this aggregate. */ + billableTotal?: number; + + /** The total spend of the matching non-billable expenses. Returned only when the query asks for this aggregate. */ + nonBillableTotal?: number; + /** The date from which violation snapshots are available for search */ violationSnapshotStartedAt?: string; }; diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index fb757dd45cd8..e7c4be00a882 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -318,6 +318,14 @@ 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'); + 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"`); @@ -877,6 +885,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('view parameter', () => { test('with view parameter set to bar', () => { const filterValues: Partial = { @@ -1066,6 +1113,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'); @@ -1223,6 +1297,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'); @@ -2059,6 +2149,67 @@ describe('SearchQueryUtils', () => { }); }); + 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(expenses?.footerCount).toEqual(CONST.SEARCH.FOOTER_COUNT.EXPENSES); + expect(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('gives each footer total selection its own primary hash, since the backend computes the aggregate', () => { + const total = buildSearchQueryJSON('type:expense footerTotal:total'); + const reimbursable = buildSearchQueryJSON('type:expense footerTotal:reimbursable'); + + expect(total?.hash).not.toEqual(reimbursable?.hash); + }); + + it('gives each footer currency selection its own primary hash', () => { + const usd = buildSearchQueryJSON('type:expense footerCurrency:USD'); + const eur = buildSearchQueryJSON('type:expense footerCurrency:EUR'); + + expect(usd?.hash).not.toEqual(eur?.hash); + }); + + it('keeps the footer total and currency selections out of the recent and similar search hashes, since they change no result', () => { + const noSelection = buildSearchQueryJSON('type:expense'); + const withSelections = buildSearchQueryJSON('type:expense 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(junk?.footerCount).toBeUndefined(); + expect(junk?.footerTotal).toBeUndefined(); + expect(junk?.hash).toEqual(noSelection?.hash); + }); + + it('uppercases the footer currency, so the same currency in either case shares one snapshot', () => { + const lower = buildSearchQueryJSON('type:expense footerCurrency:eur'); + const upper = buildSearchQueryJSON('type:expense footerCurrency:EUR'); + + expect(lower?.footerCurrency).toEqual('EUR'); + expect(lower?.hash).toEqual(upper?.hash); + }); + + 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('limit filter parsing', () => { it('parses limit value as a number', () => { const queryJSON = buildSearchQueryJSON('type:expense limit:25'); @@ -2389,6 +2540,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'); @@ -3433,6 +3605,23 @@ describe('SearchQueryUtils', () => { return null; } + describe('Spend footer selections sent to the backend', () => { + it('serializes the footer selections as top-level keys, not as filters the backend matches rows on', () => { + const queryJSON = buildSearchQueryJSON('type:expense footerCount:reports footerTotal:billable footerCurrency:EUR'); + + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + + const serialized = JSON.parse(serializeQueryJSONForBackend(queryJSON)) as Record; + + expect(serialized.footerCount).toEqual(CONST.SEARCH.FOOTER_COUNT.REPORTS); + expect(serialized.footerTotal).toEqual(CONST.SEARCH.FOOTER_TOTAL.BILLABLE); + expect(serialized.footerCurrency).toEqual('EUR'); + expect(serialized.filters).toBeNull(); + }); + }); + describe('applyContainsOperatorToTextFields', () => { it('should transform merchant eq to contains', () => { const queryJSON = buildSearchQueryJSON('type:expense merchant:coffee'); diff --git a/tests/unit/SearchParserTest.ts b/tests/unit/SearchParserTest.ts index d2b8fc260134..e4493b716450 100644 --- a/tests/unit/SearchParserTest.ts +++ b/tests/unit/SearchParserTest.ts @@ -1519,6 +1519,54 @@ const limitTests = [ }, ]; +const footerSelectionTests = [ + { + description: 'every footer selection in its canonical spelling', + query: 'type:expense-report footerCount:reports footerTotal:non-reimbursable footerCurrency:EUR', + expected: { + type: 'expense-report', + sortBy: 'date', + sortOrder: 'desc', + view: 'table', + footerCount: 'reports', + footerTotal: 'non-reimbursable', + footerCurrency: 'EUR', + filters: null, + }, + }, + { + description: 'every footer selection in its user-friendly spelling, alongside a filter', + query: 'type:expense footer-count:expenses footer-total:billable footer-currency:USD merchant:Amazon', + expected: { + type: 'expense', + sortBy: 'date', + sortOrder: 'desc', + view: 'table', + footerCount: 'expenses', + footerTotal: 'billable', + footerCurrency: 'USD', + filters: { + operator: 'eq', + left: 'merchant', + right: 'Amazon', + }, + }, + }, + { + description: 'footer selections are case-insensitive', + query: 'type:expense FOOTER-TOTAL:reimbursable FooterCount:expenses', + expected: { + type: 'expense', + sortBy: 'date', + sortOrder: 'desc', + view: 'table', + footerCount: 'expenses', + footerTotal: 'reimbursable', + filters: null, + }, + }, +]; + function parseSearchQueryWithoutRawFilters(query: string): Record { const parsed: unknown = parse(query); if (!isRecord(parsed)) { @@ -1551,3 +1599,9 @@ describe('search parser - limit filter', () => { expect(parseSearchQueryWithoutRawFilters(query)).toEqual(expected); }); }); + +describe('search parser - Spend footer selections', () => { + test.each(footerSelectionTests)('$description: $query', ({query, expected}) => { + expect(parseSearchQueryWithoutRawFilters(query)).toEqual(expected); + }); +}); From 29c32309e6a9bf694cbbae9c41b174f9f068fc00 Mon Sep 17 00:00:00 2001 From: truph01 Date: Wed, 9 Sep 2026 11:12:03 +0700 Subject: [PATCH 02/86] feat: add the count selector to the Spend footer --- src/CONST/index.ts | 2 + .../FilterDropdowns/SearchFooterPopup.tsx | 112 +++++++++++++ src/components/Search/SearchPageFooter.tsx | 41 +++-- .../Search/SearchSelectionFooter.tsx | 41 ++++- tests/unit/Search/SearchPageFooterTest.tsx | 148 ++++++++++++++++++ tests/unit/Search/SearchQueryUtilsTest.ts | 16 +- .../unit/Search/SearchSelectionFooterTest.tsx | 143 ++++++++++++++++- 7 files changed, 476 insertions(+), 27 deletions(-) create mode 100644 src/components/Search/FilterDropdowns/SearchFooterPopup.tsx create mode 100644 tests/unit/Search/SearchPageFooterTest.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index ea7a50d3cab1..39ba774790d4 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8867,6 +8867,8 @@ const CONST = { FILTER_GROUP_CURRENCY: 'Search-FilterGroupCurrency', FILTER_VIEW: 'Search-FilterView', FILTER_LIMIT: 'Search-FilterLimit', + FOOTER_COUNT: 'Search-FooterCount', + FOOTER_CURRENCY: 'Search-FooterCurrency', ADVANCED_FILTERS_BUTTON: 'Search-AdvancedFiltersButton', COLUMNS_BUTTON: 'Search-ColumnsButton', BULK_ACTIONS_DROPDOWN: 'Search-BulkActionsDropdown', diff --git a/src/components/Search/FilterDropdowns/SearchFooterPopup.tsx b/src/components/Search/FilterDropdowns/SearchFooterPopup.tsx new file mode 100644 index 000000000000..2753737c8fc9 --- /dev/null +++ b/src/components/Search/FilterDropdowns/SearchFooterPopup.tsx @@ -0,0 +1,112 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import ScrollView from '@components/ScrollView'; +import type {SingleSelectItem} from '@components/Search/FilterComponents/SingleSelect'; +import type {SearchFooterCount} from '@components/Search/types'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; + +import React, {useState} from 'react'; + +import CurrencyPopup from './CurrencyPopup'; +import SingleSelectPopup from './SingleSelectPopup'; + +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 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 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, currency, defaultCurrency, isExpanded, closeOverlay, onCountChange, onCurrencyChange}: SearchFooterPopupProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const [selectedFooterFilter, setSelectedFooterFilter] = useState(null); + console.log('>>>>>>>>>>>', {selectedFooterFilter, countType}); + const countItems: Array> = [ + {text: translate('common.expenses'), value: CONST.SEARCH.FOOTER_COUNT.EXPENSES}, + {text: translate('common.reports'), value: CONST.SEARCH.FOOTER_COUNT.REPORTS}, + ]; + + if (!selectedFooterFilter) { + return ( + + {!!countType && ( + item.value === countType)?.text} + onPress={() => setSelectedFooterFilter(CONST.SEARCH.SYNTAX_ROOT_KEYS.FOOTER_COUNT)} + sentryLabel={CONST.SENTRY_LABEL.SEARCH.FOOTER_COUNT} + /> + )} + setSelectedFooterFilter(CONST.SEARCH.SYNTAX_ROOT_KEYS.FOOTER_CURRENCY)} + sentryLabel={CONST.SENTRY_LABEL.SEARCH.FOOTER_CURRENCY} + /> + + ); + } + + const goBack = () => setSelectedFooterFilter(null); + + if (selectedFooterFilter === CONST.SEARCH.SYNTAX_ROOT_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} + /> + ); + } + + return ( + + ); +} + +export default SearchFooterPopup; diff --git a/src/components/Search/SearchPageFooter.tsx b/src/components/Search/SearchPageFooter.tsx index ec55d3cbbeb7..80faa539ac4a 100644 --- a/src/components/Search/SearchPageFooter.tsx +++ b/src/components/Search/SearchPageFooter.tsx @@ -18,17 +18,25 @@ import {StyleSheet, View} from 'react-native'; import type {SingleSelectItem} from './FilterComponents/SingleSelect'; import type {ButtonComponentProps, FilterPopupButtonProps} from './FilterDropdowns/FilterPopupButton'; +import type {SearchFooterCount} 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; @@ -43,9 +51,12 @@ type SearchPageFooterProps = { /** 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 SearchPageFooter({count, total, currency, defaultCurrency, isTotalLoading, onCurrencyChange}: SearchPageFooterProps) { +function SearchPageFooter({count, countType, defaultCountType, total, currency, defaultCurrency, isTotalLoading, onCurrencyChange, onCountChange}: SearchPageFooterProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -61,7 +72,7 @@ function SearchPageFooter({count, total, currency, defaultCurrency, isTotalLoadi 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. + // 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: isTotalButtonFocused, shouldBubble: false, shouldPreventDefault: false}); const handleCurrencyChange = (item: SingleSelectItem | undefined) => { @@ -77,16 +88,16 @@ function SearchPageFooter({count, total, currency, defaultCurrency, isTotalLoadi onCurrencyChange(nextCurrency); }; - const renderCurrencyPopup: FilterPopupButtonProps['PopoverComponent'] = ({closeOverlay, isExpanded}) => ( - ( + ); @@ -130,14 +141,14 @@ function SearchPageFooter({count, total, currency, defaultCurrency, isTotalLoadi pointerEvents={isTotalLoading ? 'none' : undefined} > - {`${translate('common.expenses')}:`} + {`${translate(countType === CONST.SEARCH.FOOTER_COUNT.REPORTS ? 'common.reports' : 'common.expenses')}:`} {count} {typeof total === 'number' && ( {`${translate('common.totalSpend')}:`} list it contains. function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { const {selectedTransactions, excludedTransactions = getEmptyObject(), areAllMatchingItemsSelected, selectedReports} = useSearchSelectionContext(); - const {currentSearchResults} = useSearchResultsContext(); + const {currentSearchResults, shouldUseLiveData} = useSearchResultsContext(); const {currentSearchHash, currentSearchKey, currentSearchQueryJSON} = useSearchQueryContext(); const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true, areAllMatchingItemsSelected); const {isOffline} = useNetwork(); @@ -140,6 +143,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { const metadata = searchResults?.search; const metadataCount = metadata?.count; + const metadataReportCount = metadata?.reportCount; const metadataCurrency = metadata?.currency; const metadataTotal = metadata?.total; const selectedTransactionsKeys = useMemo(() => Object.keys(selectedTransactions ?? {}), [selectedTransactions]); @@ -266,6 +270,17 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { const areAllSelectedForFooter = areAllMatchingItemsSelected || (selectedTransactionsKeys.length > 0 && metadataCount !== undefined && selectedExpenseCount === metadataCount); const hasPartialSelection = selectedTransactionsKeys.length > 0 && !areAllSelectedForFooter; + // Both counts come back on every search, so the selector is a client-side switch. It applies whenever the footer is + // describing the whole search — nothing selected, or everything selected with nothing excluded. A subset is only ever + // counted in expenses, and an exclusion cannot be taken off a server report count, so neither offers a choice. + // + // A to-do search is excluded too: it recomputes its expense count from live Onyx data but carries no live report + // count, so the two would be measured against different result sets. + const isFooterDescribingWholeSearch = !hasPartialSelection && excludedTransactionsKeys.length === 0; + const defaultFooterCountType = isReportsSearch ? CONST.SEARCH.FOOTER_COUNT.REPORTS : CONST.SEARCH.FOOTER_COUNT.EXPENSES; + const shouldShowCountSelector = isFooterDescribingWholeSearch && !shouldUseLiveData && (isExpenseType || isReportsSearch) && typeof metadataReportCount === 'number'; + const footerCountType = shouldShowCountSelector ? (currentSearchQueryJSON?.footerCount ?? defaultFooterCountType) : undefined; + // 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; @@ -454,6 +469,21 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { [currentSearchHash, effectiveDefaultCurrency], ); + // The count selection lives in the query (so it persists with the search) but is left out of the query hash, so + // applying it re-renders the footer from the counts this search already returned instead of running a new one. + // + // The query is rebuilt from the one being displayed 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. The form is written from the query by useSearchFilterSync, so it still follows this change. + const handleFooterCountChange = (nextCountType: SearchFooterCount) => { + if (!currentSearchQueryJSON) { + return; + } + + const nextQuery = buildSearchQueryString({...currentSearchQueryJSON, footerCount: nextCountType}); + close(() => Navigation.setParams({q: nextQuery, rawQuery: undefined})); + }; + const footerData = useMemo(() => { if (!shouldAllowFooterTotals && selectedTransactionsKeys.length === 0) { return {count: undefined, total: undefined, currency: undefined}; @@ -553,14 +583,19 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { // but don't recalculate totals, so gate on offset 0.) const isFooterTotalLoading = isFooterTotalConverting || (!hasPartialSelection && !!metadata?.isLoading && metadata?.offset === 0); + const footerCount = footerCountType === CONST.SEARCH.FOOTER_COUNT.REPORTS ? metadataReportCount : footerData.count; + return ( ); } diff --git a/tests/unit/Search/SearchPageFooterTest.tsx b/tests/unit/Search/SearchPageFooterTest.tsx new file mode 100644 index 000000000000..f20cf2cdf9fa --- /dev/null +++ b/tests/unit/Search/SearchPageFooterTest.tsx @@ -0,0 +1,148 @@ +import {render, screen} from '@testing-library/react-native'; + +import SearchPageFooter from '@components/Search/SearchPageFooter'; +import type {SearchFooterCount} from '@components/Search/types'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +type CapturedPopupProps = { + countType?: SearchFooterCount; + defaultCountType?: SearchFooterCount; + currency?: string; +}; + +const mockCapturedPopupProps: {current: CapturedPopupProps | 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'})})); +jest.mock('@hooks/useNetwork', () => ({__esModule: true, default: () => ({isOffline: false})})); +jest.mock('@hooks/useResponsiveLayout', () => ({__esModule: true, default: () => ({shouldUseNarrowLayout: false, isSmallScreenWidth: false})})); +jest.mock('@hooks/useKeyboardShortcut', () => ({__esModule: true, default: () => {}})); +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 total amount lives inside this button; the tests assert on the count label and the menu's props, so the whole +// button renders as nothing. Its compound parts still have to exist, since the footer references them. +jest.mock('@components/ButtonComposed', () => { + function MockButton() { + return null; + } + function MockButtonText() { + return null; + } + function MockButtonIcon() { + return null; + } + MockButton.Text = MockButtonText; + MockButton.Icon = MockButtonIcon; + return {__esModule: true, default: MockButton}; +}); +jest.mock('@components/Search/SearchPageFooterSkeleton', () => { + function MockSkeleton() { + return null; + } + return {__esModule: true, default: MockSkeleton}; +}); +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, + total: -192000, + currency: CONST.CURRENCY.USD, + defaultCurrency: CONST.CURRENCY.USD, + isTotalLoading: false, + onCurrencyChange: () => {}, + onCountChange: () => {}, +}; + +describe('SearchPageFooter', () => { + beforeEach(() => { + mockCapturedPopupProps.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('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, + }), + ); + }); +}); diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index e7c4be00a882..4272e050e4c7 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -2194,7 +2194,7 @@ describe('SearchQueryUtils', () => { expect(junk?.hash).toEqual(noSelection?.hash); }); - it('uppercases the footer currency, so the same currency in either case shares one snapshot', () => { + it('normalizes the footer currency to upper case, so the same currency in either case shares one snapshot', () => { const lower = buildSearchQueryJSON('type:expense footerCurrency:eur'); const upper = buildSearchQueryJSON('type:expense footerCurrency:EUR'); @@ -3613,12 +3613,16 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - const serialized = JSON.parse(serializeQueryJSONForBackend(queryJSON)) as Record; + const serialized: unknown = JSON.parse(serializeQueryJSONForBackend(queryJSON)); - expect(serialized.footerCount).toEqual(CONST.SEARCH.FOOTER_COUNT.REPORTS); - expect(serialized.footerTotal).toEqual(CONST.SEARCH.FOOTER_TOTAL.BILLABLE); - expect(serialized.footerCurrency).toEqual('EUR'); - expect(serialized.filters).toBeNull(); + expect(serialized).toEqual( + expect.objectContaining({ + footerCount: CONST.SEARCH.FOOTER_COUNT.REPORTS, + footerTotal: CONST.SEARCH.FOOTER_TOTAL.BILLABLE, + footerCurrency: 'EUR', + filters: null, + }), + ); }); }); diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx index c356272eb005..fdf32a5dc6ee 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 {SelectedTransactionInfo, SelectedTransactions} from '@components/Search/types'; +import type {SearchFooterCount, 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'; @@ -21,10 +22,22 @@ jest.mock('@libs/actions/Search', () => ({ getFooterConvertedAmounts: jest.fn(), })); +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']} | undefined; + currentSearchQueryJSON: {hash: number; type: SearchResults['search']['type']; footerCount?: SearchFooterCount} | undefined; }; const mockSearchQueryContext: {current: MockSearchQueryContext} = { @@ -46,10 +59,13 @@ jest.mock('@components/Search/SearchContext', () => ({ type CapturedFooterProps = { count?: number; + countType?: SearchFooterCount; + defaultCountType?: SearchFooterCount; total?: number; defaultCurrency?: string; currency?: string; onCurrencyChange?: (currency: string) => void; + onCountChange?: (countType: SearchFooterCount) => void; }; const mockCapturedFooterProps: {current: CapturedFooterProps | undefined} = {current: undefined}; jest.mock('@components/Search/SearchPageFooter', () => ({ @@ -72,10 +88,17 @@ const ACCOUNT_ID = 1; const PERSONAL_POLICY_ID = 'personalPolicy1'; const WORKSPACE_POLICY_ID = 'workspacePolicy1'; -function buildSearchResults(currency: string | undefined, count = 1, total = -100, type: SearchResults['search']['type'] = CONST.SEARCH.DATA_TYPES.EXPENSE): SearchResults { +function buildSearchResults( + currency: string | undefined, + count = 1, + total = -100, + type: SearchResults['search']['type'] = CONST.SEARCH.DATA_TYPES.EXPENSE, + reportCount: number | undefined = undefined, +): SearchResults { return { search: { count, + reportCount, currency, total, offset: 0, @@ -166,6 +189,120 @@ describe('SearchSelectionFooter', () => { expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 10, total: 36000, currency: CONST.CURRENCY.USD})); }); + describe('count selector', () => { + it('defaults an expense-report search to the report count', async () => { + mockSearchQueryContext.current = { + currentSearchHash: 1, + currentSearchKey: undefined, + currentSearchQueryJSON: {hash: 1, type: CONST.SEARCH.DATA_TYPES.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 () => { + mockSearchQueryContext.current = { + currentSearchHash: 1, + currentSearchKey: undefined, + currentSearchQueryJSON: {hash: 1, type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, footerCount: CONST.SEARCH.FOOTER_COUNT.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 () => { + mockSearchQueryContext.current = { + currentSearchHash: 1, + currentSearchKey: undefined, + currentSearchQueryJSON: {hash: 1, type: CONST.SEARCH.DATA_TYPES.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('offers no count selector while the footer describes a partial selection', async () => { + mockSearchQueryContext.current = { + currentSearchHash: 1, + currentSearchKey: undefined, + currentSearchQueryJSON: {hash: 1, type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, footerCount: CONST.SEARCH.FOOTER_COUNT.REPORTS}, + }; + mockSelectedTransactions.current = {transaction1: buildSelectedTransaction(CONST.CURRENCY.USD)}; + + render(); + await waitForBatchedUpdates(); + + // The selection is counted in expenses, so the report count must not be displayed against it. + expect(mockCapturedFooterProps.current?.countType).toBeUndefined(); + expect(mockCapturedFooterProps.current?.count).toBe(1); + }); + + it('offers no count selector while a select-all has exclusions, since the report count cannot account for them', async () => { + mockSelectedTransactions.current = {}; + mockExcludedTransactions.current = {transaction1: buildSelectedTransaction(CONST.CURRENCY.USD)}; + mockAreAllMatchingItemsSelected.current = true; + + render(); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current?.countType).toBeUndefined(); + expect(mockCapturedFooterProps.current?.count).toBe(171); + }); + + 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. + mockSearchQueryContext.current = {currentSearchHash: 1, currentSearchKey: undefined, currentSearchQueryJSON: buildSearchQueryJSON('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); + }); + }); + it("offers the user's live payment currency as the Reset target when there is no active workspace", async () => { // A fresh no-workspace account: the active policy is the personal policy, and the only selected expense // happens to be in a different currency (JPY) from the live payment currency (GBP). From 510bcb11715e4d29959c1ef55b9199863f0d7f52 Mon Sep 17 00:00:00 2001 From: truph01 Date: Wed, 9 Sep 2026 11:28:52 +0700 Subject: [PATCH 03/86] feat: add the count and spend total selectors to the Spend footer --- src/CONST/index.ts | 1 + .../FilterDropdowns/SearchFooterPopup.tsx | 61 ++++- src/components/Search/SearchPageFooter.tsx | 78 +++--- .../Search/SearchPageFooterSkeleton.tsx | 14 +- .../Search/SearchSelectionFooter.tsx | 70 ++++-- src/languages/de.ts | 1 + src/languages/el.ts | 1 + src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + src/libs/SearchUIUtils.ts | 38 +++ src/pages/Search/SearchPage.tsx | 20 +- src/pages/Search/SearchPageNarrow/index.tsx | 9 +- src/pages/Search/SearchPageWide.tsx | 9 +- tests/unit/Search/SearchPageFooterTest.tsx | 44 +++- .../unit/Search/SearchSelectionFooterTest.tsx | 236 ++++++++++++++++-- 22 files changed, 507 insertions(+), 84 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 39ba774790d4..bf01709c4687 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8868,6 +8868,7 @@ const CONST = { 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', diff --git a/src/components/Search/FilterDropdowns/SearchFooterPopup.tsx b/src/components/Search/FilterDropdowns/SearchFooterPopup.tsx index 2753737c8fc9..f65608191b13 100644 --- a/src/components/Search/FilterDropdowns/SearchFooterPopup.tsx +++ b/src/components/Search/FilterDropdowns/SearchFooterPopup.tsx @@ -1,11 +1,13 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import ScrollView from '@components/ScrollView'; import type {SingleSelectItem} from '@components/Search/FilterComponents/SingleSelect'; -import type {SearchFooterCount} from '@components/Search/types'; +import type {SearchFooterCount, SearchFooterTotal} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import {getFooterTotalItems} from '@libs/SearchUIUtils'; + import CONST from '@src/CONST'; import React, {useState} from 'react'; @@ -20,6 +22,12 @@ type SearchFooterPopupProps = { /** 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 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; @@ -35,6 +43,9 @@ type SearchFooterPopupProps = { /** 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; }; @@ -43,15 +54,30 @@ type SearchFooterPopupProps = { * 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, currency, defaultCurrency, isExpanded, closeOverlay, onCountChange, onCurrencyChange}: SearchFooterPopupProps) { +function SearchFooterPopup({ + countType, + defaultCountType, + totalType, + isTotalLoading, + currency, + defaultCurrency, + isExpanded, + closeOverlay, + onCountChange, + onTotalChange, + onCurrencyChange, +}: SearchFooterPopupProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const [selectedFooterFilter, setSelectedFooterFilter] = useState(null); - console.log('>>>>>>>>>>>', {selectedFooterFilter, countType}); + const [selectedFooterFilter, setSelectedFooterFilter] = useState< + typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.FOOTER_COUNT | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.FOOTER_TOTAL | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.FOOTER_CURRENCY | null + >(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 ( @@ -65,8 +91,21 @@ function SearchFooterPopup({countType, defaultCountType, currency, defaultCurren sentryLabel={CONST.SENTRY_LABEL.SEARCH.FOOTER_COUNT} /> )} + {!!totalType && ( + item.value === totalType)?.text} + onPress={() => setSelectedFooterFilter(CONST.SEARCH.SYNTAX_ROOT_KEYS.FOOTER_TOTAL)} + sentryLabel={CONST.SENTRY_LABEL.SEARCH.FOOTER_TOTAL} + /> + )} setSelectedFooterFilter(CONST.SEARCH.SYNTAX_ROOT_KEYS.FOOTER_CURRENCY)} @@ -93,6 +132,20 @@ function SearchFooterPopup({countType, defaultCountType, currency, defaultCurren ); } + if (selectedFooterFilter === CONST.SEARCH.SYNTAX_ROOT_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 ( void; + + /** Function to call when the displayed total changes */ + onTotalChange: (totalType: SearchFooterTotal) => void; }; -function SearchPageFooter({count, countType, defaultCountType, total, currency, defaultCurrency, isTotalLoading, onCurrencyChange, onCountChange}: SearchPageFooterProps) { +function SearchPageFooter({ + count, + countType, + defaultCountType, + total, + totalType, + currency, + defaultCurrency, + isTotalLoading, + onCurrencyChange, + onCountChange, + onTotalChange, +}: SearchPageFooterProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -92,22 +112,30 @@ function SearchPageFooter({count, countType, defaultCountType, total, currency, ); + // 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 totalButton = (props: ButtonComponentProps) => ( - ); - - return ( - - + - {`${translate(countType === CONST.SEARCH.FOOTER_COUNT.REPORTS ? 'common.reports' : 'common.expenses')}:`} + {`${countLabel}:`} {/* The count never loads: both counts come back on every search, so it holds still while the total reloads. */} - {count} + {count} - {typeof total === 'number' && ( + {hasTotal && ( {/* Both labels stay rendered while the total reloads, so the footer keeps its height and nothing shifts. */} - {`${totalLabel}:`} + {`${totalLabel}:`} {isTotalLoading ? ( ) : ( @@ -189,19 +184,37 @@ function SearchPageFooter({ testID="searchPageFooterTotal" onLayout={handleTotalLayout} > - + + {totalDisplayValue} + )} )} + + + ); + + return ( + + + + ); } diff --git a/tests/unit/Search/SearchPageFooterTest.tsx b/tests/unit/Search/SearchPageFooterTest.tsx index b9a86892838c..0d94a5ba8a09 100644 --- a/tests/unit/Search/SearchPageFooterTest.tsx +++ b/tests/unit/Search/SearchPageFooterTest.tsx @@ -1,4 +1,4 @@ -import {fireEvent, render, screen} from '@testing-library/react-native'; +import {fireEvent, render, screen, within} from '@testing-library/react-native'; import SearchPageFooter from '@components/Search/SearchPageFooter'; import type {SearchFooterCount, SearchFooterTotal} from '@components/Search/types'; @@ -38,14 +38,17 @@ jest.mock('@hooks/useLocalize', () => ({ jest.mock('@hooks/useCurrencyList', () => ({ useCurrencyListActions: () => ({convertToDisplayString: (amount: number | undefined, currency: string | undefined) => `${currency ?? ''}${amount ?? 0}`}), })); -// The total amount lives inside this button. The tests assert on the count label and the menu's props, so the whole -// button renders as nothing. Its compound parts still have to exist, since the footer references them. +// 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', () => { - function MockButton() { - return null; + const ReactModule = require('react'); + const {View: MockView, Text: MockText} = require('react-native'); + + function MockButton({children}: {children?: React.ReactNode}) { + return ReactModule.createElement(MockView, {testID: 'searchPageFooterTrigger'}, children); } - function MockButtonText() { - return null; + function MockButtonText({children}: {children?: React.ReactNode}) { + return ReactModule.createElement(MockText, null, children); } function MockButtonIcon() { return null; @@ -170,7 +173,18 @@ describe('SearchPageFooter', () => { expect(screen.getByText('common.expenses:')).toBeOnTheScreen(); expect(screen.getByText('1204')).toBeOnTheScreen(); expect(screen.getByText('common.totalSpend:')).toBeOnTheScreen(); - expect(mockCapturedPopupProps.current).toBeUndefined(); + // 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('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', () => { diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index 1701a43a8a10..6a488caa5b8e 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -1353,7 +1353,7 @@ describe('SearchQueryUtils', () => { throw new Error('Failed to parse query string'); } - const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}, {}); + const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}); expect(result).toMatchObject({ footerCount: CONST.SEARCH.FOOTER_COUNT.REPORTS, From 13deba7020935db2c7102c32ee1318ed54d6b8aa Mon Sep 17 00:00:00 2001 From: truph01 Date: Thu, 24 Sep 2026 00:25:16 +0700 Subject: [PATCH 21/86] fix: lint --- tests/unit/Search/SearchPageFooterTest.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/Search/SearchPageFooterTest.tsx b/tests/unit/Search/SearchPageFooterTest.tsx index 0d94a5ba8a09..8545c541d568 100644 --- a/tests/unit/Search/SearchPageFooterTest.tsx +++ b/tests/unit/Search/SearchPageFooterTest.tsx @@ -5,6 +5,8 @@ import type {SearchFooterCount, SearchFooterTotal} from '@components/Search/type import CONST from '@src/CONST'; +import type * as ReactNative from 'react-native'; + import React from 'react'; type CapturedPopupProps = { @@ -42,7 +44,7 @@ jest.mock('@hooks/useCurrencyList', () => ({ // has nothing to assert on, renders as nothing. jest.mock('@components/Button', () => { const ReactModule = require('react'); - const {View: MockView, Text: MockText} = require('react-native'); + const {View: MockView, Text: MockText} = require('react-native'); function MockButton({children}: {children?: React.ReactNode}) { return ReactModule.createElement(MockView, {testID: 'searchPageFooterTrigger'}, children); From f093b81173ffe000518794e7894d5c3bb8c6405d Mon Sep 17 00:00:00 2001 From: truph01 Date: Thu, 24 Sep 2026 00:44:24 +0700 Subject: [PATCH 22/86] fix: lint --- tests/unit/Search/SearchPageFooterTest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/Search/SearchPageFooterTest.tsx b/tests/unit/Search/SearchPageFooterTest.tsx index 8545c541d568..2a006f0b5a24 100644 --- a/tests/unit/Search/SearchPageFooterTest.tsx +++ b/tests/unit/Search/SearchPageFooterTest.tsx @@ -5,7 +5,7 @@ import type {SearchFooterCount, SearchFooterTotal} from '@components/Search/type import CONST from '@src/CONST'; -import type * as ReactNative from 'react-native'; +import type ReactNative from 'react-native'; import React from 'react'; From 698ab7f48dc16b4e8b48bbd9efbfd63c4e98e59e Mon Sep 17 00:00:00 2001 From: truph01 Date: Thu, 24 Sep 2026 18:01:35 +0700 Subject: [PATCH 23/86] feat: key the search snapshot on the Spend footer total --- .../Search/SearchSelectionFooter.tsx | 18 ++++---- src/hooks/useSearchPageSetup.ts | 9 ++++ src/libs/SearchQueryUtils.ts | 18 ++++++++ tests/unit/Search/SearchQueryUtilsTest.ts | 21 ++++++++-- .../unit/Search/SearchSelectionFooterTest.tsx | 41 ++++++++++--------- tests/unit/useSearchPageSetupTest.tsx | 27 +++++++++++- 6 files changed, 102 insertions(+), 32 deletions(-) diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index 53b5ed31e4cd..755d7f8e8483 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -6,7 +6,7 @@ import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotal import {close} from '@libs/actions/Modal'; import {getFooterConvertedAmounts} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; -import {getFooterSelectionFromQuery, getQueryWithFooterSelection} from '@libs/SearchQueryUtils'; +import {buildSearchQueryJSON, getFooterSelectionFromQuery, getQueryWithFooterSelection} from '@libs/SearchQueryUtils'; import {isGroupEntry} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; @@ -548,17 +548,19 @@ function SearchSelectionFooter({searchResults, onDisplayChange}: SearchSelection }; const handleFooterTotalChange = (nextTotalType: SearchFooterTotal) => { - // A hand-picked selection is summed from its own rows, so it shows the new breakdown straight away and waits on - // nothing. Every other case needs the figure from the backend, which the query change below fetches, so that is - // the search the skeleton waits on. + // A hand-picked selection is summed from its own rows, so it shows the new breakdown straight away and waits + // on nothing. Every other case needs the figure from the backend, so the skeleton waits on the hash the query + // is moving to — `footerTotal` is part of the hash, since it changes the aggregate that comes back. if (hasPartialSelection) { setFooterTotalState({searchHash: currentSearchHash, selectedTotal: nextTotalType}); - } else { - setPendingTotalHash(currentSearchHash); + } else if (currentSearchQueryJSON) { + setPendingTotalHash(buildSearchQueryJSON(getQueryWithFooterSelection(currentSearchQueryJSON, {footerTotal: nextTotalType}))?.hash); } - // Written in both cases, so the choice is saved against this search and restored on the next visit. - applyFooterSelection({footerTotal: nextTotalType}); + // Written in both cases, so the choice is saved against this search and restored on the next visit. The page + // is told first so the rows stay on screen while the new hash loads, and the selection rides it out: + // `useSearchPageSetup` keeps a selection across a query change that is only a footer selection. + applyFooterSelection({footerTotal: nextTotalType}, true); }; const handleFooterCountChange = (nextCountType: SearchFooterCount) => { diff --git a/src/hooks/useSearchPageSetup.ts b/src/hooks/useSearchPageSetup.ts index 41379d632161..872a89db52b0 100644 --- a/src/hooks/useSearchPageSetup.ts +++ b/src/hooks/useSearchPageSetup.ts @@ -4,6 +4,7 @@ import type {SearchQueryJSON} from '@components/Search/types'; import {saveLastSearchParams} from '@libs/actions/ReportNavigation'; import {openSearch, search} from '@libs/actions/Search'; import {hasDeferredWrite} from '@libs/deferredLayoutWrite'; +import {getQueryHashWithoutFooterSelections} from '@libs/SearchQueryUtils'; import {isSearchDataLoaded, isSearchPending} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; @@ -59,11 +60,19 @@ function useSearchPageSetup(queryJSON: Readonly | undefined) { // Hashes this page requested, so an error can be traced to the attempt that produced it. const requestedHashesRef = useRef>(new Set()); + // The Spend footer's total is part of the hash, since the backend answers a different aggregate for it, but it + // is the same search with the same rows. Telling the two apart keeps a selection alive across a footer switch. + const hashWithoutFooterSelections = queryJSON ? getQueryHashWithoutFooterSelections(queryJSON) : undefined; + const prevHashWithoutFooterSelections = usePrevious(hashWithoutFooterSelections); + // Clear selected transactions when navigating to a different search query function clearOnHashChange() { if (hash === undefined) { return; } + if (hashWithoutFooterSelections !== undefined && hashWithoutFooterSelections === prevHashWithoutFooterSelections) { + return; + } clearSelectedTransactions(hash); } diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 41e942a54f7f..d674a3fb4891 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -975,11 +975,28 @@ function getQueryHashes(query: SearchQueryJSON) { if (query.limit !== undefined) { orderedQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT}:${query.limit}`; } + + // The total the footer asks for changes the aggregate the backend answers with, so it is part of the primary + // hash — the snapshot key. It joins here, after the recent and similar hashes are taken, so switching the + // breakdown is still the same search in the recent list and still matches the same saved search. + const {footerTotal} = getFooterSelectionFromQuery(query); + if (footerTotal) { + orderedQuery += ` ${CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_TOTAL}:${footerTotal}`; + } + const primaryHash = hashText(orderedQuery, 2 ** 32); return {primaryHash, recentSearchHash, similarSearchHash}; } +/** + * The primary hash a query would have with the Spend footer's selections taken out. Two queries sharing it are the + * same search with a different footer selection, which is what lets a selection survive a footer-driven re-run. + */ +function getQueryHashWithoutFooterSelections(query: SearchQueryJSON | Readonly): number { + return getQueryHashes({...query, flatFilters: query.flatFilters.filter((filter) => !FOOTER_FILTER_KEYS.has(filter.key))}).primaryHash; +} + function withExactMatchFilterKeys(queryJSON: Readonly, exactMatchFilterKeys: SearchFilterKey[]): SearchQueryJSON { const queryWithExactMatches = {...queryJSON, exactMatchFilterKeys}; const {primaryHash, recentSearchHash, similarSearchHash} = getQueryHashes(queryWithExactMatches); @@ -3037,6 +3054,7 @@ export { getQueryHashes, getFooterSelectionFromQuery, getQueryWithFooterSelection, + getQueryHashWithoutFooterSelections, hasFiltersChangedFromDefault, withExactMatchFilterKeys, isSearchDatePreset, diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index fd39e41593b6..4988d72e1423 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -25,6 +25,7 @@ import { getFilterFormValues, getFilterFromQuery, getFooterSelectionFromQuery, + getQueryHashWithoutFooterSelections, queryHasViolationFilter, hasValuesIncludeViolationFilter, getDateFilterRange, @@ -2448,14 +2449,26 @@ describe('SearchQueryUtils', () => { expect(expenses?.similarSearchHash).toEqual(noSelection?.similarSearchHash); }); - it('leaves every hash alone for the footer total, which the footer refreshes into the snapshot on screen', () => { + it('moves the primary hash for the footer total, which is the one selection the backend answers differently', () => { const noSelection = buildSearchQueryJSON('type:expense'); const total = buildSearchQueryJSON('type:expense footerTotal:total'); const reimbursable = buildSearchQueryJSON('type:expense footerTotal:reimbursable'); - // A new hash would mean a new snapshot, so the search would re-run and take the selection with it. - expect(total?.hash).toEqual(reimbursable?.hash); - expect(total?.hash).toEqual(noSelection?.hash); + // Each breakdown is its own snapshot, so the aggregate the backend sends back is cached per breakdown. + expect(total?.hash).not.toEqual(reimbursable?.hash); + expect(reimbursable?.hash).not.toEqual(noSelection?.hash); + // ...but it is the same search in the recent list, and still matches the same saved search. + expect(reimbursable?.recentSearchHash).toEqual(noSelection?.recentSearchHash); + expect(reimbursable?.similarSearchHash).toEqual(noSelection?.similarSearchHash); + }); + + it('gives every breakdown of one search the same footerless hash, which is what keeps a selection across it', () => { + const noSelection = buildSearchQueryJSON('type:expense'); + const billable = buildSearchQueryJSON('type:expense footerTotal:billable'); + const otherSearch = buildSearchQueryJSON('type:expense footerTotal:billable sortBy:amount'); + + expect(getQueryHashWithoutFooterSelections(billable)).toEqual(getQueryHashWithoutFooterSelections(noSelection)); + expect(getQueryHashWithoutFooterSelections(otherSearch)).not.toEqual(getQueryHashWithoutFooterSelections(billable)); }); it('leaves every hash alone for the footer currency, which Search ignores and a separate command converts', () => { diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx index fd6a22771386..217f1d88b2a6 100644 --- a/tests/unit/Search/SearchSelectionFooterTest.tsx +++ b/tests/unit/Search/SearchSelectionFooterTest.tsx @@ -412,7 +412,7 @@ describe('SearchSelectionFooter', () => { expect(mockCapturedFooterProps.current?.totalType).toBeUndefined(); }); - it('refreshes the figures in place when a total is applied, without moving the search hash', async () => { + it('moves the search hash when a total is applied, keeping the rows on screen while the new one loads', async () => { setSearchQuery('type:expense'); mockSelectedTransactions.current = {}; const beforeHash = mockSearchQueryContext.current.currentSearchQueryJSON?.hash; @@ -436,9 +436,10 @@ describe('SearchSelectionFooter', () => { // The choice goes into the query, which is what 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, which is what keeps the rows, the scroll position and the selection in place. - expect(buildSearchQueryJSON(nextQuery)?.hash).toBe(beforeHash); - expect(mockOnDisplayChange).not.toHaveBeenCalled(); + // ...and the hash moves with it, since the backend answers a different aggregate for each breakdown. + expect(buildSearchQueryJSON(nextQuery)?.hash).not.toBe(beforeHash); + // The page is told first, so the current rows stay on screen while the new snapshot loads. + expect(mockOnDisplayChange).toHaveBeenCalled(); }); it("skeletons the total after applying one while another search's results are still on screen, leaving the count alone", async () => { @@ -457,11 +458,21 @@ describe('SearchSelectionFooter', () => { // Then nothing is waited on until the footer asks for something expect(mockCapturedFooterProps.current?.isTotalLoading).toBe(false); - // When a different total is applied + // When a different total is applied, which moves the query 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); + rerender( + , + ); + 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); @@ -469,7 +480,7 @@ describe('SearchSelectionFooter', () => { // When this search's own results arrive const nextResults = buildSearchResults(CONST.CURRENCY.USD, 10, 12000, CONST.SEARCH.DATA_TYPES.EXPENSE, 4); - nextResults.search.hash = 2; + nextResults.search.hash = nextHash; rerender( { it('stores a total applied over a selection in the query too, so it is restored on the next visit', async () => { setSearchQuery('type:expense'); - const {rerender} = render( + render( { await waitForBatchedUpdates(); }); - // The choice is written into the query, and since the total is not part of the hash the selected rows stay. + // The choice goes into the query like any other, so it survives a reload and a saved search. The hash it + // moves is a footer-only change, which `useSearchPageSetup` keeps the selection across. const nextQuery = mockSetParams.mock.calls.at(0)?.at(0)?.q ?? ''; expect(nextQuery).toContain('footerTotal:billable'); - expect(mockOnDisplayChange).not.toHaveBeenCalled(); - - // Replaying the query the app is now on: the breakdown covers only the billable expense of the three. - setSearchQuery(nextQuery); - rerender( - , - ); - await waitForBatchedUpdates(); + expect(mockOnDisplayChange).toHaveBeenCalled(); + // The breakdown applies right away all the same, summed from the selected rows: one of the three is billable. expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({totalType: CONST.SEARCH.FOOTER_TOTAL.BILLABLE, total: -100})); }); diff --git a/tests/unit/useSearchPageSetupTest.tsx b/tests/unit/useSearchPageSetupTest.tsx index b4787e859936..b6c64a32721d 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(); let mockSearchResults: SearchResults | undefined; @@ -49,7 +50,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}), })); @@ -83,6 +84,7 @@ function getClearedHashes() { describe('useSearchPageSetup', () => { beforeEach(() => { + mockClearSelectedTransactions.mockClear(); mockOpenSearch.mockClear(); mockSearch.mockClear(); mockSearchResults = undefined; @@ -116,6 +118,29 @@ describe('useSearchPageSetup', () => { expect(getClearedHashes()).toEqual([queryJSON?.hash]); }); + it('keeps the selection when only the Spend footer total changes, which is the same search', () => { + // Given a search with rows selected + const {rerender} = renderHook(({queryJSON: currentQueryJSON}) => useSearchPageSetup(currentQueryJSON), {initialProps: {queryJSON}}); + mockClearSelectedTransactions.mockClear(); + + // When the footer asks for a different total, which moves the hash because the backend answers differently + const withBreakdown = buildSearchQueryJSON(`${QUERY} footerTotal:billable`); + expect(withBreakdown?.hash).not.toBe(queryJSON?.hash); + rerender({queryJSON: withBreakdown}); + + // Then the selection the footer is describing survives it + expect(mockClearSelectedTransactions).not.toHaveBeenCalled(); + }); + + 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); From 7310577dbc562e00e16f7e042b678cf4e3d15588 Mon Sep 17 00:00:00 2001 From: truph01 Date: Thu, 24 Sep 2026 18:35:38 +0700 Subject: [PATCH 24/86] fix: typecheck --- .../Search/SearchSelectionFooter.tsx | 5 ++++- src/hooks/useSearchPageSetup.ts | 2 +- tests/unit/Search/SearchQueryUtilsTest.ts | 11 +++++----- .../unit/Search/SearchSelectionFooterTest.tsx | 20 +++++++++++++++++++ 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index 755d7f8e8483..2b5774e04800 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -682,7 +682,10 @@ function SearchSelectionFooter({searchResults, onDisplayChange}: SearchSelection // 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. - const selectedReportCount = isReportsSearch ? selectedReports.length : new Set(selectedTransactionsKeys.map((key) => selectedTransactions[key]?.reportID).filter(Boolean)).size; + // 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. diff --git a/src/hooks/useSearchPageSetup.ts b/src/hooks/useSearchPageSetup.ts index 872a89db52b0..e1f76067523d 100644 --- a/src/hooks/useSearchPageSetup.ts +++ b/src/hooks/useSearchPageSetup.ts @@ -80,7 +80,7 @@ function useSearchPageSetup(queryJSON: Readonly | undefined) { // useEffect supplements useFocusEffect: it handles both the initial mount // and cases where route params change without a navigation event (e.g. sorting). - useEffect(clearOnHashChange, [hash, clearSelectedTransactions]); + useEffect(clearOnHashChange, [hash, clearSelectedTransactions, hashWithoutFooterSelections, prevHashWithoutFooterSelections]); // Fire search() when the query changes (hash). This runs at the page level so the // API request starts in parallel with the skeleton, before Search mounts its 14+ useOnyx hooks. diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index 4988d72e1423..8612fefea6d3 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -2463,12 +2463,13 @@ describe('SearchQueryUtils', () => { }); it('gives every breakdown of one search the same footerless hash, which is what keeps a selection across it', () => { - const noSelection = buildSearchQueryJSON('type:expense'); - const billable = buildSearchQueryJSON('type:expense footerTotal:billable'); - const otherSearch = buildSearchQueryJSON('type:expense footerTotal:billable sortBy:amount'); + const getFooterlessHash = (query: string) => { + const queryJSON = buildSearchQueryJSON(query); + return queryJSON ? getQueryHashWithoutFooterSelections(queryJSON) : undefined; + }; - expect(getQueryHashWithoutFooterSelections(billable)).toEqual(getQueryHashWithoutFooterSelections(noSelection)); - expect(getQueryHashWithoutFooterSelections(otherSearch)).not.toEqual(getQueryHashWithoutFooterSelections(billable)); + expect(getFooterlessHash('type:expense footerTotal:billable')).toEqual(getFooterlessHash('type:expense')); + expect(getFooterlessHash('type:expense footerTotal:billable sortBy:amount')).not.toEqual(getFooterlessHash('type:expense footerTotal:billable')); }); it('leaves every hash alone for the footer currency, which Search ignores and a separate command converts', () => { diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx index 217f1d88b2a6..6bc81d133dcf 100644 --- a/tests/unit/Search/SearchSelectionFooterTest.tsx +++ b/tests/unit/Search/SearchSelectionFooterTest.tsx @@ -737,6 +737,26 @@ describe('SearchSelectionFooter', () => { 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 = { From 1787b6b53cc9a3934286757b16329156717a970a Mon Sep 17 00:00:00 2001 From: truph01 Date: Fri, 25 Sep 2026 01:25:09 +0700 Subject: [PATCH 25/86] fix: conflicts --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 7ac02f2fa94d..48f7653987aa 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 7ac02f2fa94df2d6ed5a67d53bf35b145f371594 +Subproject commit 48f7653987aa8bb7e8921757dd19d2cc68dcb9f8 From d696036ba0959c727caa80e841d46bb23d35af19 Mon Sep 17 00:00:00 2001 From: truph01 Date: Fri, 25 Sep 2026 02:17:06 +0700 Subject: [PATCH 26/86] fix: correct the Spend footer's report count, breakdown rule and pending wait --- .../Search/SearchSelectionFooter.tsx | 20 +++++++++++++---- .../unit/Search/SearchSelectionFooterTest.tsx | 22 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index 2b5774e04800..630627edff2f 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -8,6 +8,7 @@ import {getFooterConvertedAmounts} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; import {buildSearchQueryJSON, getFooterSelectionFromQuery, getQueryWithFooterSelection} from '@libs/SearchQueryUtils'; import {isGroupEntry} from '@libs/SearchUIUtils'; +import {getReimbursable} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -67,16 +68,18 @@ function getTransactionCount(transactionKeys: string[], transactions: SelectedTr } // Whether a selected row belongs in the total the footer is showing. Reimbursable is the product default, so only an -// explicit `false` makes an expense non-reimbursable. Billable works the other way round. A row with no transaction of -// its own (an empty report group) has no expense to classify, so it counts towards no breakdown. +// explicit `false` makes an expense non-reimbursable — read through `getReimbursable`, the same helper the row's own +// Reimbursable column renders, so the breakdown and the column can never disagree about a row. Billable works the +// other way round. A row with no transaction of its own (an empty report group) has no expense to classify, so it +// counts towards no breakdown. function matchesFooterTotal(entry: SelectedTransactionInfo, totalType: SearchFooterTotal): boolean { const transaction = entry.transaction; switch (totalType) { case CONST.SEARCH.FOOTER_TOTAL.REIMBURSABLE: - return !!transaction && transaction.reimbursable !== false; + return !!transaction && getReimbursable(transaction); case CONST.SEARCH.FOOTER_TOTAL.NON_REIMBURSABLE: - return !!transaction && transaction.reimbursable === false; + return !!transaction && !getReimbursable(transaction); case CONST.SEARCH.FOOTER_TOTAL.BILLABLE: return !!transaction && transaction.billable === true; case CONST.SEARCH.FOOTER_TOTAL.NON_BILLABLE: @@ -396,6 +399,15 @@ function SearchSelectionFooter({searchResults, onDisplayChange}: SearchSelection (shouldUseClientTotal ? hasConvertibleSelection && !areAllSelectedConverted : !isSearchTotalFresh || (hasExcludedExpenses && !areAllExcludedConverted)); const shouldShowFooter = (!areAllMatchingItemsSelected && selectedTransactionsKeys.length > 0) || (shouldAllowFooterTotals && !!metadata?.count); + // The search the footer asked for has answered, so stop waiting on it. Left set, it would skeleton the total + // again on any later visit to that hash whose snapshot has not landed yet. + useEffect(() => { + if (pendingTotalHash === undefined || metadata?.hash !== pendingTotalHash) { + return; + } + setPendingTotalHash(undefined); + }, [metadata?.hash, pendingTotalHash]); + // Fetch converted figures whenever a custom currency is chosen and no request has covered what the footer needs. // Each request stamps the source figures it converts, so the requested checks keep this to one request per // out-of-coverage change (or per edit) rather than one per checkbox or render. diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx index 6bc81d133dcf..42d48309f05b 100644 --- a/tests/unit/Search/SearchSelectionFooterTest.tsx +++ b/tests/unit/Search/SearchSelectionFooterTest.tsx @@ -491,6 +491,18 @@ describe('SearchSelectionFooter', () => { // Then the skeleton gives way to the figure expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({isTotalLoading: false, total: 12000})); + + // And the wait is over for good: a later visit to that same search, with its snapshot not yet on screen, + // shows the figures it has rather than skeletoning on a request that was already answered. + rerender( + , + ); + await waitForBatchedUpdates(); + + expect(mockCapturedFooterProps.current?.isTotalLoading).toBe(false); }); it('leaves the total alone while a search the footer did not ask for runs, so it does not flicker', async () => { @@ -599,6 +611,16 @@ describe('SearchSelectionFooter', () => { 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}), From 23693d99d9c2c347bf6fddf393655c463066aeaf Mon Sep 17 00:00:00 2001 From: truph01 Date: Fri, 25 Sep 2026 16:29:23 +0700 Subject: [PATCH 27/86] fix: lint --- .../Search/SearchSelectionFooter.tsx | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index 630627edff2f..01367ecf8b00 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -150,8 +150,9 @@ function SearchSelectionFooter({searchResults, onDisplayChange}: SearchSelection selectedTotal: undefined, }); // The hash the footer is waiting on after asking for a different total, which is the only search whose result - // changes the figure on display. - const [pendingTotalHash, setPendingTotalHash] = useState(undefined); + // changes the figure on display, stamped with the snapshot hash the request was made from. The stamp ends the wait + // on its own once the snapshot moves off it, so a later visit to that hash never skeletons the total again. + const [pendingTotal, setPendingTotal] = useState<{hash: number | undefined; fromHash: number | undefined}>(); const footerSelection = getFooterSelectionFromQuery(currentSearchQueryJSON); 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 @@ -399,15 +400,6 @@ function SearchSelectionFooter({searchResults, onDisplayChange}: SearchSelection (shouldUseClientTotal ? hasConvertibleSelection && !areAllSelectedConverted : !isSearchTotalFresh || (hasExcludedExpenses && !areAllExcludedConverted)); const shouldShowFooter = (!areAllMatchingItemsSelected && selectedTransactionsKeys.length > 0) || (shouldAllowFooterTotals && !!metadata?.count); - // The search the footer asked for has answered, so stop waiting on it. Left set, it would skeleton the total - // again on any later visit to that hash whose snapshot has not landed yet. - useEffect(() => { - if (pendingTotalHash === undefined || metadata?.hash !== pendingTotalHash) { - return; - } - setPendingTotalHash(undefined); - }, [metadata?.hash, pendingTotalHash]); - // Fetch converted figures whenever a custom currency is chosen and no request has covered what the footer needs. // Each request stamps the source figures it converts, so the requested checks keep this to one request per // out-of-coverage change (or per edit) rather than one per checkbox or render. @@ -566,7 +558,7 @@ function SearchSelectionFooter({searchResults, onDisplayChange}: SearchSelection if (hasPartialSelection) { setFooterTotalState({searchHash: currentSearchHash, selectedTotal: nextTotalType}); } else if (currentSearchQueryJSON) { - setPendingTotalHash(buildSearchQueryJSON(getQueryWithFooterSelection(currentSearchQueryJSON, {footerTotal: nextTotalType}))?.hash); + setPendingTotal({hash: buildSearchQueryJSON(getQueryWithFooterSelection(currentSearchQueryJSON, {footerTotal: nextTotalType}))?.hash, fromHash: metadata?.hash}); } // Written in both cases, so the choice is saved against this search and restored on the next visit. The page @@ -687,7 +679,8 @@ function SearchSelectionFooter({searchResults, onDisplayChange}: SearchSelection return null; } - const isAwaitingFooterTotal = pendingTotalHash !== undefined && pendingTotalHash === currentSearchHash && metadata?.hash !== currentSearchHash; + const isAwaitingFooterTotal = + pendingTotal?.hash !== undefined && pendingTotal.hash === currentSearchHash && metadata?.hash !== currentSearchHash && metadata?.hash === pendingTotal.fromHash; // A partial selection shows a client-side subtotal that is ready immediately, so it never waits on a search. const isFooterTotalLoading = isFooterTotalConverting || (!hasPartialSelection && isAwaitingFooterTotal); From 3d78ab81c75c56e8db90e14a7e6a5f8b7911b217 Mon Sep 17 00:00:00 2001 From: truph01 Date: Fri, 25 Sep 2026 17:22:21 +0700 Subject: [PATCH 28/86] fix: conflicts --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 48f7653987aa..18a0c966c430 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 48f7653987aa8bb7e8921757dd19d2cc68dcb9f8 +Subproject commit 18a0c966c43074db63f14cc13c0b5a72bacbfef3 From 6f74c66fd2ed9fa3b6f05a931d89fa1929852782 Mon Sep 17 00:00:00 2001 From: truph01 Date: Fri, 25 Sep 2026 17:32:51 +0700 Subject: [PATCH 29/86] fix: typecheck --- .../unit/Search/SearchSelectionFooterTest.tsx | 52 ++++++++++--------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx index fab1322dd5d1..eaf3fd319ee6 100644 --- a/tests/unit/Search/SearchSelectionFooterTest.tsx +++ b/tests/unit/Search/SearchSelectionFooterTest.tsx @@ -220,11 +220,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'); mockSelectedTransactions.current = {transaction3: buildSelectedTransaction(CONST.CURRENCY.USD, undefined, -100, 'report2')}; mockExcludedTransactions.current = { transaction1: buildSelectedTransaction(CONST.CURRENCY.USD, undefined, -100, 'report1'), @@ -245,45 +241,43 @@ describe('SearchSelectionFooter', () => { }); 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}, - }; + 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}, - }; + 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(); + render( + , + ); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({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'), @@ -291,7 +285,12 @@ describe('SearchSelectionFooter', () => { mockSelectedReports.current = [buildSelectedReport('report2', -100)]; mockAreAllMatchingItemsSelected.current = true; const searchResults = buildSearchResults(CONST.CURRENCY.USD, 2, 200, CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT); - const {rerender} = render(); + const {rerender} = render( + , + ); await waitForBatchedUpdates(); await act(async () => { @@ -302,7 +301,12 @@ describe('SearchSelectionFooter', () => { // Reconciliation can provide an equivalent selectedReports array before Onyx publishes the optimistic source // stamp. That render must not issue the same report conversion again. mockSelectedReports.current = [...mockSelectedReports.current]; - rerender(); + rerender( + , + ); await waitForBatchedUpdates(); const reportConversionCalls = jest.mocked(getFooterConvertedAmounts).mock.calls.filter(([params]) => params.reportIDList === 'report1'); From 3a31c094c726ad1357cb6161f222105cf7821005 Mon Sep 17 00:00:00 2001 From: truph01 Date: Fri, 25 Sep 2026 18:43:33 +0700 Subject: [PATCH 30/86] fix: skeleton the Spend footer total while its search is fetched again --- src/components/Search/SearchSelectionFooter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index a67cff474b39..1841d1e3c226 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -743,7 +743,7 @@ function SearchSelectionFooter({searchResults, onDisplayChange}: SearchSelection pendingTotal?.hash !== undefined && pendingTotal.hash === currentSearchHash && metadata?.hash !== currentSearchHash && metadata?.hash === pendingTotal.fromHash; // A partial selection shows a client-side subtotal that is ready immediately, so it never waits on a search. - const isFooterTotalLoading = isFooterTotalConverting || (!hasPartialSelection && isAwaitingFooterTotal); + const isFooterTotalLoading = isFooterTotalConverting || (!hasPartialSelection && (isAwaitingFooterTotal || (!!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. From dbd188890652ad46c82f811f766eaba47126c84d Mon Sep 17 00:00:00 2001 From: truph01 Date: Fri, 25 Sep 2026 18:52:11 +0700 Subject: [PATCH 31/86] fix: test --- .../unit/Search/SearchSelectionFooterTest.tsx | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx index eaf3fd319ee6..0c8eb06a29bf 100644 --- a/tests/unit/Search/SearchSelectionFooterTest.tsx +++ b/tests/unit/Search/SearchSelectionFooterTest.tsx @@ -257,7 +257,8 @@ describe('SearchSelectionFooter', () => { expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 10, total: 36000, currency: CONST.CURRENCY.USD})); }); - it('counts the expenses inside manually selected reports', async () => { + 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'), @@ -265,7 +266,18 @@ describe('SearchSelectionFooter', () => { }; mockSelectedReports.current = [buildSelectedReport('report1', -200)]; - render( + const {rerender} = render( + , + ); + await waitForBatchedUpdates(); + + 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({count: 2, total: 200, currency: CONST.CURRENCY.USD})); + 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 () => { @@ -589,18 +601,6 @@ describe('SearchSelectionFooter', () => { // Then the skeleton gives way to the figure expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({isTotalLoading: false, total: 12000})); - - // And the wait is over for good: a later visit to that same search, with its snapshot not yet on screen, - // shows the figures it has rather than skeletoning on a request that was already answered. - rerender( - , - ); - await waitForBatchedUpdates(); - - expect(mockCapturedFooterProps.current?.isTotalLoading).toBe(false); }); it('leaves the total alone while a search the footer did not ask for runs, so it does not flicker', async () => { From 41d5498c17113382d84f9083d59149471effbebd Mon Sep 17 00:00:00 2001 From: truph01 Date: Sat, 26 Sep 2026 11:20:19 +0700 Subject: [PATCH 32/86] fix: crash issue and remove redundant changes --- .../Search/SearchSelectionFooter.tsx | 16 +- src/hooks/useSearchBulkActions.ts | 2 +- src/pages/Search/SearchPage.tsx | 22 +- src/pages/Search/SearchPageNarrow/index.tsx | 8 +- src/pages/Search/SearchPageWide.tsx | 8 +- .../unit/Search/SearchSelectionFooterTest.tsx | 329 +++--------------- 6 files changed, 62 insertions(+), 323 deletions(-) diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index 1841d1e3c226..00ba514c9fcb 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -27,9 +27,6 @@ import SearchPageFooter from './SearchPageFooter'; type SearchSelectionFooterProps = { /** The (sorting-aware) results the page is displaying; source of the footer's totals metadata. */ searchResults: OnyxEntry; - - /** Called before a footer selector re-runs the search, so the page keeps the current results while it loads. */ - onDisplayChange: () => void; }; type FooterCurrencyState = { @@ -131,7 +128,7 @@ function areAllSelectedReportsConverted(selectedReportIDs: string[], isReportFre // Self-subscribing footer leaf. Owns the `selectedTransactions` read so a checkbox press re-renders only this // footer — not SearchPage and the list it contains. -function SearchSelectionFooter({searchResults, onDisplayChange}: SearchSelectionFooterProps) { +function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { const {selectedTransactions, excludedTransactions = getEmptyObject(), areAllMatchingItemsSelected, selectedReports} = useSearchSelectionContext(); const {currentSearchResults, shouldUseLiveData} = useSearchResultsContext(); const {currentSearchHash, currentSearchKey, currentSearchQueryJSON} = useSearchQueryContext(); @@ -576,20 +573,13 @@ function SearchSelectionFooter({searchResults, onDisplayChange}: SearchSelection // 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. - // - // `shouldReloadResults` says whether the backend's answer actually changes. Only `footerTotal` does, by swapping - // which aggregate comes back as the search total, so only that one enters the query hash and re-runs the search. The page - // is told first, so the rows stay on screen while it loads: the selection never changes which rows match. - const applyFooterSelection = (selection: {footerCount?: SearchFooterCount; footerTotal?: SearchFooterTotal; footerCurrency?: string}, shouldReloadResults = false) => { + const applyFooterSelection = (selection: {footerCount?: SearchFooterCount; footerTotal?: SearchFooterTotal; footerCurrency?: string}) => { if (!currentSearchQueryJSON) { return; } const nextQuery = getQueryWithFooterSelection(currentSearchQueryJSON, selection); close(() => { - if (shouldReloadResults) { - onDisplayChange(); - } Navigation.setParams({q: nextQuery, rawQuery: undefined}); }); }; @@ -616,7 +606,7 @@ function SearchSelectionFooter({searchResults, onDisplayChange}: SearchSelection // Written in both cases, so the choice is saved against this search and restored on the next visit. The page // is told first so the rows stay on screen while the new hash loads, and the selection rides it out: // `useSearchPageSetup` keeps a selection across a query change that is only a footer selection. - applyFooterSelection({footerTotal: nextTotalType}, true); + applyFooterSelection({footerTotal: nextTotalType}); }; const handleFooterCountChange = (nextCountType: SearchFooterCount) => { diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 8b55d1cf0580..f4699df1fdf1 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -2774,7 +2774,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/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index fc0b79f2a56a..00d3c3efd8ce 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -76,9 +76,7 @@ function SearchPage({route}: SearchPageProps) { const {resetVideoPlayerData} = usePlaybackActionsContext(); - // A sort, or a footer total/currency change, re-runs the query under a new hash while matching the same rows. Keep - // the previous results on screen for those, so the list does not blank out on a change that cannot alter it. - const [isReloadingResults, setIsReloadingResults] = useState(false); + const [isSorting, setIsSorting] = useState(false); const isCurrentSearchResolved = isSearchDataLoaded(currentSearchResults, currentSearchQueryJSON); let searchResults: SearchResults | undefined; @@ -86,7 +84,7 @@ function SearchPage({route}: SearchPageProps) { searchResults = {...currentSearchResults, data: {}}; } else if (currentSearchResults?.data != null || currentSearchResults?.errors) { searchResults = currentSearchResults; - } else if (isReloadingResults) { + } else if (isSorting) { searchResults = lastNonEmptySearchResults; } @@ -110,12 +108,12 @@ function SearchPage({route}: SearchPageProps) { const prevIsLoading = usePrevious(currentSearchResults?.isLoading); useEffect(() => { - if (!isReloadingResults || !prevIsLoading || currentSearchResults?.isLoading) { + if (!isSorting || !prevIsLoading || currentSearchResults?.isLoading) { return; } - setIsReloadingResults(false); - }, [currentSearchResults?.isLoading, isReloadingResults, prevIsLoading]); + setIsSorting(false); + }, [currentSearchResults?.isLoading, isSorting, prevIsLoading]); const [lastResolvedSearch, setLastResolvedSearch] = useState<{queryJSON: SearchQueryJSON; searchResults: SearchResults} | undefined>(undefined); @@ -157,7 +155,9 @@ function SearchPage({route}: SearchPageProps) { } }, []); - const startResultsReload = () => setIsReloadingResults(true); + const onSortPressedCallback = useCallback(() => { + setIsSorting(true); + }, []); const overlayContentContainerStyle = !isMobileSelectionModeEnabled ? styles.searchListContentContainerStyles(!!hasFilterBars) : undefined; const overlayEndSubmitSpans = useEndSubmitNavigationSpans(); @@ -182,8 +182,7 @@ function SearchPage({route}: SearchPageProps) { contentQueryJSON={contentQueryJSON} contentSearchResults={contentSearchResults} isMobileSelectionModeEnabled={isMobileSelectionModeEnabled} - onSortPressedCallback={startResultsReload} - onFooterDisplayChange={startResultsReload} + onSortPressedCallback={onSortPressedCallback} searchOverlayContent={searchOverlayContent} onSearchContentReady={onSearchContentReady} hasFilterBars={hasFilterBars} @@ -197,8 +196,7 @@ function SearchPage({route}: SearchPageProps) { contentSearchResults={contentSearchResults} isMobileSelectionModeEnabled={isMobileSelectionModeEnabled} handleSearchAction={handleSearchAction} - onSortPressedCallback={startResultsReload} - onFooterDisplayChange={startResultsReload} + onSortPressedCallback={onSortPressedCallback} route={route} searchOverlayContent={searchOverlayContent} onSearchContentReady={onSearchContentReady} diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 4daebefae0d8..b2cc7d62e9e1 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -67,8 +67,6 @@ type SearchPageNarrowProps = { isMobileSelectionModeEnabled: boolean; onSortPressedCallback: () => void; - /** Called when a footer selector re-runs the search, so the current results stay on screen while it loads */ - onFooterDisplayChange: () => void; /** Overlay rendered above Search content during expense-creation flows (SearchStaticList or null). */ searchOverlayContent: React.ReactNode; /** Callback for Search to signal that real content is ready and the overlay can be dismissed. */ @@ -88,7 +86,6 @@ function SearchPageNarrow({ contentSearchResults, isMobileSelectionModeEnabled, onSortPressedCallback, - onFooterDisplayChange, searchOverlayContent, onSearchContentReady, hasFilterBars, @@ -394,10 +391,7 @@ function SearchPageNarrow({ )} - + diff --git a/src/pages/Search/SearchPageWide.tsx b/src/pages/Search/SearchPageWide.tsx index b9c0313b7361..555277622cb3 100644 --- a/src/pages/Search/SearchPageWide.tsx +++ b/src/pages/Search/SearchPageWide.tsx @@ -51,8 +51,6 @@ type SearchPageWideProps = { handleSearchAction: (value: SearchParams | string) => void; onSortPressedCallback: () => void; - /** Called when a footer selector re-runs the search, so the current results stay on screen while it loads */ - onFooterDisplayChange: () => void; route: PlatformStackRouteProp; /** Overlay rendered above Search content during expense-creation flows (SearchStaticList or null). */ searchOverlayContent: React.ReactNode; @@ -68,7 +66,6 @@ function SearchPageWide({ isMobileSelectionModeEnabled, handleSearchAction, onSortPressedCallback, - onFooterDisplayChange, route, searchOverlayContent, onSearchContentReady, @@ -172,10 +169,7 @@ function SearchPageWide({ {/* Floats over the bottom of the list, which already ends above SearchSelectionFooter. */} - + )} diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx index 0c8eb06a29bf..92c411579c75 100644 --- a/tests/unit/Search/SearchSelectionFooterTest.tsx +++ b/tests/unit/Search/SearchSelectionFooterTest.tsx @@ -25,7 +25,6 @@ jest.mock('@libs/actions/Search', () => ({ })); const mockSetParams = jest.fn(); -const mockOnDisplayChange = jest.fn(); jest.mock('@libs/Navigation/Navigation', () => ({ __esModule: true, // Called through a wrapper: the factory runs before the mock above is initialized. @@ -208,12 +207,7 @@ describe('SearchSelectionFooter', () => { mockExcludedTransactions.current = {transaction1: buildSelectedTransaction(CONST.CURRENCY.USD)}; mockAreAllMatchingItemsSelected.current = true; - render( - , - ); + render(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 171, total: 35900, currency: CONST.CURRENCY.USD})); @@ -229,12 +223,7 @@ describe('SearchSelectionFooter', () => { mockSelectedReports.current = [buildSelectedReport('report2', -100)]; mockAreAllMatchingItemsSelected.current = true; - render( - , - ); + render(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 8, total: 35800, currency: CONST.CURRENCY.USD})); @@ -246,12 +235,7 @@ describe('SearchSelectionFooter', () => { 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})); @@ -266,23 +250,13 @@ describe('SearchSelectionFooter', () => { }; mockSelectedReports.current = [buildSelectedReport('report1', -200)]; - const {rerender} = render( - , - ); + const {rerender} = render(); await waitForBatchedUpdates(); 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( - , - ); + rerender(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.EXPENSES, count: 2, total: 200, currency: CONST.CURRENCY.USD})); @@ -297,12 +271,7 @@ describe('SearchSelectionFooter', () => { mockSelectedReports.current = [buildSelectedReport('report2', -100)]; mockAreAllMatchingItemsSelected.current = true; const searchResults = buildSearchResults(CONST.CURRENCY.USD, 2, 200, CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT); - const {rerender} = render( - , - ); + const {rerender} = render(); await waitForBatchedUpdates(); await act(async () => { @@ -313,12 +282,7 @@ describe('SearchSelectionFooter', () => { // Reconciliation can provide an equivalent selectedReports array before Onyx publishes the optimistic source // stamp. That render must not issue the same report conversion again. mockSelectedReports.current = [...mockSelectedReports.current]; - rerender( - , - ); + rerender(); await waitForBatchedUpdates(); const reportConversionCalls = jest.mocked(getFooterConvertedAmounts).mock.calls.filter(([params]) => params.reportIDList === 'report1'); @@ -337,12 +301,7 @@ describe('SearchSelectionFooter', () => { transaction2: {...buildSelectedTransaction(CONST.CURRENCY.USD), displayAmount: -10000}, }; - render( - , - ); + render(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 2, total: 0})); @@ -354,12 +313,7 @@ describe('SearchSelectionFooter', () => { transaction2: {...buildSelectedTransaction(CONST.CURRENCY.USD), displayAmount: -4000}, }; - render( - , - ); + render(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 2, total: 6000})); @@ -371,12 +325,7 @@ describe('SearchSelectionFooter', () => { mockExcludedTransactions.current = {transaction1: {...buildSelectedTransaction(CONST.CURRENCY.USD), displayAmount: -10000}}; mockAreAllMatchingItemsSelected.current = true; - render( - , - ); + render(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 171, total: 46000, currency: CONST.CURRENCY.USD})); @@ -385,12 +334,7 @@ describe('SearchSelectionFooter', () => { it("offers the user's live payment currency as the Reset target when there is no active workspace", async () => { // A fresh no-workspace account: the active policy is the personal policy, and the only selected expense // happens to be in a different currency (JPY) from the live payment currency (GBP). - render( - , - ); + render(); await waitForBatchedUpdates(); // The footer's Reset/default currency follows the live payment currency (the personal policy's output @@ -405,12 +349,7 @@ describe('SearchSelectionFooter', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${WORKSPACE_POLICY_ID}`, {id: WORKSPACE_POLICY_ID, outputCurrency: CONST.CURRENCY.EUR}); await waitForBatchedUpdates(); - render( - , - ); + render(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current?.defaultCurrency).toBe(CONST.CURRENCY.EUR); @@ -422,12 +361,7 @@ describe('SearchSelectionFooter', () => { mockSelectedTransactions.current = {[`${CONST.SEARCH.GROUP_PREFIX}category1`]: buildSelectedTransaction(SELECTED_EXPENSE_CURRENCY, 'INR', -100)}; // A partial selection (1 of 2), so the footer uses the client-side selected total. - render( - , - ); + render(); await waitForBatchedUpdates(); // No picker choice yet, so nothing converts. @@ -446,12 +380,7 @@ describe('SearchSelectionFooter', () => { it('does not convert when Reset selects the currency the figures are already denominated in', async () => { mockSelectedTransactions.current = {[`${CONST.SEARCH.GROUP_PREFIX}category1`]: buildSelectedTransaction(SELECTED_EXPENSE_CURRENCY, PAYMENT_CURRENCY, -100)}; - render( - , - ); + render(); await waitForBatchedUpdates(); await act(async () => { @@ -468,12 +397,7 @@ describe('SearchSelectionFooter', () => { it('defaults to the plain total spend and reads the aggregate the query selects', async () => { mockSelectedTransactions.current = {}; - const {rerender} = render( - , - ); + const {rerender} = render(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({totalType: CONST.SEARCH.FOOTER_TOTAL.TOTAL, total: 36000})); @@ -482,12 +406,7 @@ describe('SearchSelectionFooter', () => { // 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( - , - ); + rerender(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({totalType: CONST.SEARCH.FOOTER_TOTAL.REIMBURSABLE, total: 12000})); @@ -497,12 +416,7 @@ describe('SearchSelectionFooter', () => { setSearchQuery('type:expense footerTotal:billable'); mockSelectedTransactions.current = {}; - render( - , - ); + render(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current?.total).toBe(4200); @@ -511,12 +425,7 @@ describe('SearchSelectionFooter', () => { it('offers no total selector on an empty result set', async () => { mockSelectedTransactions.current = {}; - render( - , - ); + render(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current?.totalType).toBeUndefined(); @@ -527,12 +436,7 @@ describe('SearchSelectionFooter', () => { mockSelectedTransactions.current = {}; const beforeHash = mockSearchQueryContext.current.currentSearchQueryJSON?.hash; - render( - , - ); + render(); await waitForBatchedUpdates(); await act(async () => { @@ -548,8 +452,6 @@ describe('SearchSelectionFooter', () => { expect(nextQuery).toContain('footerTotal:reimbursable'); // ...and the hash moves with it, since the backend answers a different aggregate for each breakdown. expect(buildSearchQueryJSON(nextQuery)?.hash).not.toBe(beforeHash); - // The page is told first, so the current rows stay on screen while the new snapshot loads. - expect(mockOnDisplayChange).toHaveBeenCalled(); }); it("skeletons the total after applying one while another search's results are still on screen, leaving the count alone", async () => { @@ -557,12 +459,7 @@ describe('SearchSelectionFooter', () => { setSearchQuery('type:expense', 2); mockSelectedTransactions.current = {}; - const {rerender} = render( - , - ); + const {rerender} = render(); await waitForBatchedUpdates(); // Then nothing is waited on until the footer asks for something @@ -576,12 +473,7 @@ describe('SearchSelectionFooter', () => { const nextQuery = mockSetParams.mock.calls.at(0)?.at(0)?.q ?? ''; const nextHash = buildSearchQueryJSON(nextQuery)?.hash ?? 0; setSearchQuery(nextQuery, nextHash); - rerender( - , - ); + rerender(); await waitForBatchedUpdates(); // Then the skeleton stands in for the figure it cannot describe yet, and the count holds its value @@ -591,12 +483,7 @@ describe('SearchSelectionFooter', () => { // When this search's own results arrive const nextResults = buildSearchResults(CONST.CURRENCY.USD, 10, 12000, CONST.SEARCH.DATA_TYPES.EXPENSE, 4); nextResults.search.hash = nextHash; - rerender( - , - ); + rerender(); await waitForBatchedUpdates(); // Then the skeleton gives way to the figure @@ -608,12 +495,7 @@ describe('SearchSelectionFooter', () => { setSearchQuery('type:expense', 2); mockSelectedTransactions.current = {}; - render( - , - ); + render(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current?.isTotalLoading).toBe(false); @@ -632,12 +514,7 @@ describe('SearchSelectionFooter', () => { async function renderWithTotal(totalType: SearchFooterTotal) { setSearchQuery(`type:expense footerTotal:${totalType}`); - render( - , - ); + render(); await waitForBatchedUpdates(); return mockCapturedFooterProps.current; } @@ -666,12 +543,7 @@ describe('SearchSelectionFooter', () => { setSearchQuery(`type:expense-report footerTotal:${CONST.SEARCH.FOOTER_TOTAL.REIMBURSABLE}`); mockSelectedTransactions.current = {reimbursable1: buildFlaggedTransaction(100, {reimbursable: true})}; - render( - , - ); + render(); await waitForBatchedUpdates(); await act(async () => { @@ -686,12 +558,7 @@ describe('SearchSelectionFooter', () => { it('stores a total applied over a selection in the query too, so it is restored on the next visit', async () => { setSearchQuery('type:expense'); - render( - , - ); + render(); await waitForBatchedUpdates(); await act(async () => { @@ -703,7 +570,6 @@ describe('SearchSelectionFooter', () => { // moves is a footer-only change, which `useSearchPageSetup` keeps the selection across. const nextQuery = mockSetParams.mock.calls.at(0)?.at(0)?.q ?? ''; expect(nextQuery).toContain('footerTotal:billable'); - expect(mockOnDisplayChange).toHaveBeenCalled(); // The breakdown applies right away all the same, summed from the selected rows: one of the three is billable. expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({totalType: CONST.SEARCH.FOOTER_TOTAL.BILLABLE, total: -100})); @@ -734,12 +600,7 @@ describe('SearchSelectionFooter', () => { setSearchQuery('type:expense'); mockSelectedTransactions.current = {}; - render( - , - ); + render(); await waitForBatchedUpdates(); await act(async () => { @@ -751,19 +612,13 @@ describe('SearchSelectionFooter', () => { 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'); - expect(mockOnDisplayChange).not.toHaveBeenCalled(); }); it('starts from the currency the query carries, so a saved search reopens on it', async () => { setSearchQuery('type:expense footerCurrency:EUR'); mockSelectedTransactions.current = {}; - render( - , - ); + render(); await waitForBatchedUpdates(); expect(getFooterConvertedAmounts).toHaveBeenCalledWith(expect.objectContaining({targetCurrency: CONST.CURRENCY.EUR})); @@ -775,12 +630,7 @@ describe('SearchSelectionFooter', () => { setSearchQuery('type:expense-report'); mockSelectedTransactions.current = {}; - render( - , - ); + render(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current).toEqual( @@ -791,12 +641,7 @@ describe('SearchSelectionFooter', () => { it('defaults an expense search to the expense count', async () => { mockSelectedTransactions.current = {}; - render( - , - ); + render(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current).toEqual( @@ -808,12 +653,7 @@ describe('SearchSelectionFooter', () => { setSearchQuery('type:expense-report footerCount:expenses'); mockSelectedTransactions.current = {}; - render( - , - ); + render(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 1204, countType: CONST.SEARCH.FOOTER_COUNT.EXPENSES})); @@ -825,12 +665,7 @@ describe('SearchSelectionFooter', () => { mockSelectedTransactions.current = {transaction1: buildSelectedTransaction(CONST.CURRENCY.USD)}; mockAreAllMatchingItemsSelected.current = true; - render( - , - ); + render(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({count: 87, countType: CONST.SEARCH.FOOTER_COUNT.REPORTS})); @@ -845,12 +680,7 @@ describe('SearchSelectionFooter', () => { transaction3: {...buildSelectedTransaction(CONST.CURRENCY.USD), reportID: 'report2'}, }; - render( - , - ); + render(); await waitForBatchedUpdates(); // The server's 87 describes the whole search, so the selection reports its own two. @@ -866,12 +696,7 @@ describe('SearchSelectionFooter', () => { transaction3: {...buildSelectedTransaction(CONST.CURRENCY.USD), reportID: CONST.REPORT.UNREPORTED_REPORT_ID}, }; - render( - , - ); + render(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.REPORTS, count: 1})); @@ -884,12 +709,7 @@ describe('SearchSelectionFooter', () => { transaction2: {...buildSelectedTransaction(CONST.CURRENCY.USD), reportID: 'report1'}, }; - render( - , - ); + render(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.EXPENSES, count: 2})); @@ -900,12 +720,7 @@ describe('SearchSelectionFooter', () => { mockSelectedTransactions.current = {}; // A grouped search counts expenses only, so the server sends no report count to switch to. - render( - , - ); + render(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current?.countType).toBeUndefined(); @@ -920,12 +735,7 @@ describe('SearchSelectionFooter', () => { nonReimbursable1: buildFlaggedTransaction(300, {reimbursable: false}), }; - render( - , - ); + render(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current).toEqual( @@ -942,12 +752,7 @@ describe('SearchSelectionFooter', () => { transaction2: {...buildFlaggedTransaction(300, {reimbursable: false}), groupKey: 'report1', isSelectedViaGroup: true}, }; - render( - , - ); + render(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.REPORTS, totalType: CONST.SEARCH.FOOTER_TOTAL.TOTAL, total: -400})); @@ -957,12 +762,7 @@ describe('SearchSelectionFooter', () => { setSearchQuery('type:expense groupBy:category'); mockSelectedTransactions.current = {[`${CONST.SEARCH.GROUP_PREFIX}category1`]: buildSelectedTransaction(CONST.CURRENCY.USD, CONST.CURRENCY.USD, -100)}; - render( - , - ); + render(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current?.countType).toBeUndefined(); @@ -977,12 +777,7 @@ describe('SearchSelectionFooter', () => { transaction2: {...buildFlaggedTransaction(300, {reimbursable: false}), groupKey: `${CONST.SEARCH.GROUP_PREFIX}category1`, isSelectedViaGroup: true}, }; - render( - , - ); + render(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current?.countType).toBeUndefined(); @@ -995,12 +790,7 @@ describe('SearchSelectionFooter', () => { transaction1: {...buildFlaggedTransaction(100, {reimbursable: true}), groupKey: `${CONST.SEARCH.GROUP_PREFIX}category1`, isSelectedViaGroup: false}, }; - render( - , - ); + render(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.EXPENSES, totalType: CONST.SEARCH.FOOTER_TOTAL.TOTAL})); @@ -1012,12 +802,7 @@ describe('SearchSelectionFooter', () => { mockExcludedTransactions.current = {transaction1: buildSelectedTransaction(CONST.CURRENCY.USD)}; mockAreAllMatchingItemsSelected.current = true; - render( - , - ); + render(); await waitForBatchedUpdates(); // A select-all covers rows that were never loaded, so only the server can say how many reports they span. @@ -1033,12 +818,7 @@ describe('SearchSelectionFooter', () => { transaction3: {...buildSelectedTransaction(CONST.CURRENCY.USD), reportID: 'report2'}, }; - const {rerender} = render( - , - ); + const {rerender} = render(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.EXPENSES, count: 3})); @@ -1051,17 +831,10 @@ describe('SearchSelectionFooter', () => { // 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'); - // ...and the hash is unchanged, so the selected rows are not cleared. - expect(mockOnDisplayChange).not.toHaveBeenCalled(); // Replaying the query the app would now be on: the footer counts the two reports those expenses sit on. setSearchQuery(nextQuery ?? ''); - rerender( - , - ); + rerender(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.REPORTS, count: 2})); @@ -1072,12 +845,7 @@ describe('SearchSelectionFooter', () => { setSearchQuery('type:expense'); mockSelectedTransactions.current = {}; - render( - , - ); + render(); await waitForBatchedUpdates(); await act(async () => { @@ -1092,12 +860,7 @@ describe('SearchSelectionFooter', () => { it('offers no count selector when the search returned no report count', async () => { mockSelectedTransactions.current = {}; - render( - , - ); + render(); await waitForBatchedUpdates(); expect(mockCapturedFooterProps.current?.countType).toBeUndefined(); From a95a916e103d70932a78f6dd708d8994f751a401 Mon Sep 17 00:00:00 2001 From: truph01 Date: Sat, 26 Sep 2026 11:47:47 +0700 Subject: [PATCH 33/86] fix: show footerCount in todo search --- src/components/Search/SearchSelectionFooter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index 00ba514c9fcb..c2ec3a5fda66 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -348,7 +348,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { // 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 = (isExpenseType || isReportsSearch) && !hasGroupSelection && (hasPartialSelection || (!shouldUseLiveData && typeof metadataReportCount === 'number')); + const shouldShowCountSelector = (isExpenseType || isReportsSearch) && !hasGroupSelection; const footerCountType = shouldShowCountSelector ? (footerSelection.footerCount ?? defaultFooterCountType) : undefined; // An empty result set has no total to break down, so it keeps the plain total spend with no selector. From c36c150f210f0b88ea91086ca25bdb27fee2c295 Mon Sep 17 00:00:00 2001 From: truph01 Date: Sat, 26 Sep 2026 17:51:03 +0700 Subject: [PATCH 34/86] fix: test --- tests/unit/Search/SearchSelectionFooterTest.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx index 92c411579c75..0eee0b47363e 100644 --- a/tests/unit/Search/SearchSelectionFooterTest.tsx +++ b/tests/unit/Search/SearchSelectionFooterTest.tsx @@ -214,7 +214,7 @@ describe('SearchSelectionFooter', () => { }); it("subtracts an excluded report's expenses and total from the all-matching footer", async () => { - setSearchQuery('type: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'), @@ -230,7 +230,7 @@ describe('SearchSelectionFooter', () => { }); it('shows the authoritative expense count and total before every report page is loaded', async () => { - setSearchQuery('type:expense-report'); + setSearchQuery('type:expense-report footerCount:expenses'); mockSelectedTransactions.current = {transaction1: buildSelectedTransaction(CONST.CURRENCY.USD, undefined, -100, 'report1')}; mockSelectedReports.current = [buildSelectedReport('report1', -100)]; mockAreAllMatchingItemsSelected.current = true; @@ -715,16 +715,16 @@ describe('SearchSelectionFooter', () => { 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 () => { + it('offers both selectors on a grouped search with nothing selected, counting expenses', 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})); + expect(mockCapturedFooterProps.current).toEqual( + expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.EXPENSES, totalType: CONST.SEARCH.FOOTER_TOTAL.TOTAL, count: 1204, total: 36000}), + ); }); it('offers both selectors on a grouped search once individual expenses are selected', async () => { @@ -857,13 +857,13 @@ describe('SearchSelectionFooter', () => { 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 () => { + it('keeps the count on expenses when the search returned no report count', async () => { mockSelectedTransactions.current = {}; render(); await waitForBatchedUpdates(); - expect(mockCapturedFooterProps.current?.countType).toBeUndefined(); + expect(mockCapturedFooterProps.current?.countType).toBe(CONST.SEARCH.FOOTER_COUNT.EXPENSES); expect(mockCapturedFooterProps.current?.count).toBe(1204); }); }); From 60a6040e90d352708fb3de7b55e2f4c41cd517b0 Mon Sep 17 00:00:00 2001 From: truph01 Date: Sat, 26 Sep 2026 18:04:12 +0700 Subject: [PATCH 35/86] fix: lint --- src/components/Search/SearchSelectionFooter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index c2ec3a5fda66..cfa25007bad8 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -130,7 +130,7 @@ 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, shouldUseLiveData} = useSearchResultsContext(); + const {currentSearchResults} = useSearchResultsContext(); const {currentSearchHash, currentSearchKey, currentSearchQueryJSON} = useSearchQueryContext(); const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, true, areAllMatchingItemsSelected); const {isOffline} = useNetwork(); From 0665516e7b7f677cc035dfe60a8f65cc059627ad Mon Sep 17 00:00:00 2001 From: truph01 Date: Sat, 26 Sep 2026 18:08:55 +0700 Subject: [PATCH 36/86] fix: remove redundant change --- src/pages/Search/SearchPageWide.tsx | 1 - src/types/onyx/SearchResults.ts | 7 +++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/pages/Search/SearchPageWide.tsx b/src/pages/Search/SearchPageWide.tsx index 555277622cb3..b4ed227e6c5e 100644 --- a/src/pages/Search/SearchPageWide.tsx +++ b/src/pages/Search/SearchPageWide.tsx @@ -50,7 +50,6 @@ type SearchPageWideProps = { isMobileSelectionModeEnabled: boolean; handleSearchAction: (value: SearchParams | string) => void; onSortPressedCallback: () => void; - route: PlatformStackRouteProp; /** Overlay rendered above Search content during expense-creation flows (SearchStaticList or null). */ searchOverlayContent: React.ReactNode; diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index 3db37aabc4ac..80bdf7788ddc 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -80,12 +80,11 @@ type SearchResultsInfo = { /** The number of results */ count?: number; - /** The number of matching reports across all pages. Distinct from `count`, which is the number of expenses; used to - * label "Select all matching" on the Reports tab and to switch the Spend footer's count. Absent on grouped searches, - * which count expenses only. */ + /** The number of matching reports across all pages, returned by the server for expense-report searches. + * Distinct from `count`, which is the number of expenses; used to label "Select all matching" on the Reports tab. */ reportCount?: number; - /** The total spend. Swapped for the aggregate the query's `footerTotal` filter asks for, if any. */ + /** The total spend */ total?: number; /** The currency of the total spend */ From 780469830e556bbe768216964a37059ea3db5e52 Mon Sep 17 00:00:00 2001 From: truph01 Date: Sat, 26 Sep 2026 18:11:10 +0700 Subject: [PATCH 37/86] fix: remove redundant change --- src/pages/Search/SearchPageNarrow/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index b2cc7d62e9e1..1e321b3c20e4 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -66,7 +66,6 @@ type SearchPageNarrowProps = { isMobileSelectionModeEnabled: boolean; onSortPressedCallback: () => void; - /** Overlay rendered above Search content during expense-creation flows (SearchStaticList or null). */ searchOverlayContent: React.ReactNode; /** Callback for Search to signal that real content is ready and the overlay can be dismissed. */ From 6aff2cd593c02faa34786ff5c89c4a596ccab7f9 Mon Sep 17 00:00:00 2001 From: truph01 Date: Sat, 26 Sep 2026 19:13:42 +0700 Subject: [PATCH 38/86] fix: correct total calculation in live onyx data --- .../Search/SearchResultsProvider.tsx | 6 +++- .../Search/SearchSelectionFooter.tsx | 29 ++----------------- src/hooks/useTodoSearchResults.ts | 16 +++++++--- src/libs/SearchQueryUtils.ts | 2 +- src/libs/SearchUIUtils.ts | 23 +++++++++++++++ 5 files changed, 44 insertions(+), 32 deletions(-) diff --git a/src/components/Search/SearchResultsProvider.tsx b/src/components/Search/SearchResultsProvider.tsx index 1b8d5620bba8..5ca917123d40 100644 --- a/src/components/Search/SearchResultsProvider.tsx +++ b/src/components/Search/SearchResultsProvider.tsx @@ -1,5 +1,6 @@ import useTodoSearchResults from '@hooks/useTodoSearchResults'; +import {getFooterSelectionFromQuery} from '@libs/SearchQueryUtils'; import {getTransactionsByReportID, getViolationsFromSearchData, isTodoSearch} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; @@ -46,7 +47,10 @@ 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. + const {footerTotal} = getFooterSelectionFromQuery(currentSearchQueryJSON); + 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 cfa25007bad8..efcce4a491e9 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -7,8 +7,7 @@ import {close} from '@libs/actions/Modal'; import {getFooterConvertedAmounts} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; import {buildSearchQueryJSON, getFooterSelectionFromQuery, getQueryWithFooterSelection} from '@libs/SearchQueryUtils'; -import {isGroupEntry} from '@libs/SearchUIUtils'; -import {getReimbursable} from '@libs/TransactionUtils'; +import {doesTransactionMatchFooterTotal, isGroupEntry} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -64,28 +63,6 @@ function getTransactionCount(transactionKeys: string[], transactions: SelectedTr }, 0); } -// Whether a selected row belongs in the total the 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, so the breakdown and the column can never disagree about a row. Billable works the -// other way round. A row with no transaction of its own (an empty report group) has no expense to classify, so it -// counts towards no breakdown. -function matchesFooterTotal(entry: SelectedTransactionInfo, totalType: SearchFooterTotal): boolean { - const transaction = entry.transaction; - - 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; - } -} - // The live default-currency figure a row contributes to the footer total (also what the footer falls back to before a // conversion arrives). The footer stamps each conversion against this value and compares it on every render, so an // inline edit that moves it is detected and the cached conversion is fetched again. @@ -639,7 +616,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { } else { total = selectedTransactionsKeys.reduce((acc, key) => { const transaction = selectedTransactions[key]; - if (!matchesFooterTotal(transaction, footerTotalBreakdown)) { + if (!doesTransactionMatchFooterTotal(transaction.transaction, footerTotalBreakdown)) { return acc; } let convertedAmount; @@ -657,7 +634,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { return {count: selectedExpenseCount, total, currency: shouldConvertSelectedTotal ? selectedCurrency : fallbackCurrency}; } - const excludedKeysForTotal = excludedTransactionsKeys.filter((key) => matchesFooterTotal(excludedTransactions[key], footerTotalBreakdown)); + const excludedKeysForTotal = excludedTransactionsKeys.filter((key) => doesTransactionMatchFooterTotal(excludedTransactions[key]?.transaction, footerTotalBreakdown)); if ( hasCustomFooterCurrency && isSearchTotalFresh && 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 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()); diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 68e6027bbbe0..addcaf443b0d 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -226,6 +226,7 @@ import { getMCCForDisplay, getOriginalAmountForDisplay, getReceiptTypeTranslationKey, + getReimbursable, getReportOwnerAccountIDAsAttendee, getReportOwnerAsAttendee, getTag, @@ -4656,6 +4657,27 @@ function isTransactionSearchType(type: string | undefined): boolean { return type === CONST.SEARCH.DATA_TYPES.EXPENSE || type === CONST.SEARCH.DATA_TYPES.INVOICE; } +/** + * 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); @@ -7250,6 +7272,7 @@ function isTransactionMatchWithGroupItem(transaction: OnyxTypes.Transaction, gro } export { + doesTransactionMatchFooterTotal, getSearchBulkEditPolicyID, getSuggestedSearches, getSections, From 180d60d7022fdd8991300e79bea2a8e324cf28ba Mon Sep 17 00:00:00 2001 From: truph01 Date: Sat, 26 Sep 2026 19:20:13 +0700 Subject: [PATCH 39/86] fix: typecheck --- src/libs/SearchUIUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index addcaf443b0d..78a03b7ba9c0 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -4663,7 +4663,7 @@ function isTransactionSearchType(type: string | undefined): boolean { * 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 { +function doesTransactionMatchFooterTotal(transaction: OnyxEntry, totalType: SearchFooterTotal | undefined): boolean { switch (totalType) { case CONST.SEARCH.FOOTER_TOTAL.REIMBURSABLE: return !!transaction && getReimbursable(transaction); From 23d1b0f8e9f78aedc95452b1d9d7cdeeca54dc5d Mon Sep 17 00:00:00 2001 From: truph01 Date: Sat, 26 Sep 2026 20:24:55 +0700 Subject: [PATCH 40/86] fix: remove redundant skeleton effect --- src/components/Search/SearchSelectionFooter.tsx | 1 + src/pages/Search/SearchPageNarrow/index.tsx | 6 +++--- src/pages/Search/SearchPageWide.tsx | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index efcce4a491e9..074f9ad98675 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -6,6 +6,7 @@ import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotal import {close} from '@libs/actions/Modal'; import {getFooterConvertedAmounts} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; +import {markQueryAsRefinement} from '@libs/SearchQueryRefinement'; import {buildSearchQueryJSON, getFooterSelectionFromQuery, getQueryWithFooterSelection} from '@libs/SearchQueryUtils'; import {doesTransactionMatchFooterTotal, isGroupEntry} from '@libs/SearchUIUtils'; diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 1e321b3c20e4..1b667f216b57 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -28,7 +28,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import Navigation from '@libs/Navigation/Navigation'; -import {buildCannedSearchQuery} from '@libs/SearchQueryUtils'; +import {buildCannedSearchQuery, getQueryHashWithoutFooterSelections} from '@libs/SearchQueryUtils'; import {isSearchDataLoaded, isSearchPending} from '@libs/SearchUIUtils'; import {getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; @@ -333,7 +333,7 @@ function SearchPageNarrow({ diff --git a/src/pages/Search/SearchPageWide.tsx b/src/pages/Search/SearchPageWide.tsx index b4ed227e6c5e..d9ae683bd549 100644 --- a/src/pages/Search/SearchPageWide.tsx +++ b/src/pages/Search/SearchPageWide.tsx @@ -20,7 +20,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; -import {buildCannedSearchQuery} from '@libs/SearchQueryUtils'; +import {buildCannedSearchQuery, getQueryHashWithoutFooterSelections} from '@libs/SearchQueryUtils'; import Navigation from '@navigation/Navigation'; @@ -144,7 +144,7 @@ function SearchPageWide({ /> From a6cae54f3a228f6c68ab53e45cc00230f51d96ca Mon Sep 17 00:00:00 2001 From: truph01 Date: Sat, 26 Sep 2026 20:28:58 +0700 Subject: [PATCH 41/86] fix: lint --- src/components/Search/SearchSelectionFooter.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index 074f9ad98675..efcce4a491e9 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -6,7 +6,6 @@ import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotal import {close} from '@libs/actions/Modal'; import {getFooterConvertedAmounts} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; -import {markQueryAsRefinement} from '@libs/SearchQueryRefinement'; import {buildSearchQueryJSON, getFooterSelectionFromQuery, getQueryWithFooterSelection} from '@libs/SearchQueryUtils'; import {doesTransactionMatchFooterTotal, isGroupEntry} from '@libs/SearchUIUtils'; From 16b52797db438a6dbb1172ce070d9a93a2d281a1 Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 29 Sep 2026 09:58:25 +0700 Subject: [PATCH 42/86] resolve conflicts --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 93e558ec9b42..be6570d405b4 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 93e558ec9b42e37ef54003330f1dd03d038488b5 +Subproject commit be6570d405b47095ebc0f1895241dfb470fc4328 From a0430dad001e4ca8a6724114ced6f17e95e6b623 Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 29 Sep 2026 10:39:09 +0700 Subject: [PATCH 43/86] fix: keep the Spend list and selection while a footer total loads --- .../Search/SearchSelectionFooter.tsx | 9 ++++++-- src/pages/Search/SearchPage.tsx | 23 +++++++++++++++---- src/pages/Search/SearchPageNarrow/index.tsx | 2 +- src/pages/Search/SearchPageWide.tsx | 2 +- .../unit/Search/SearchSelectionFooterTest.tsx | 14 +++++------ 5 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index efcce4a491e9..7bdb9e6a30d2 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -6,6 +6,7 @@ import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotal import {close} from '@libs/actions/Modal'; import {getFooterConvertedAmounts} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; +import {markQueryAsRefinement} from '@libs/SearchQueryRefinement'; import {buildSearchQueryJSON, getFooterSelectionFromQuery, getQueryWithFooterSelection} from '@libs/SearchQueryUtils'; import {doesTransactionMatchFooterTotal, isGroupEntry} from '@libs/SearchUIUtils'; @@ -107,7 +108,7 @@ 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, shouldUseLiveData} = useSearchResultsContext(); const {currentSearchHash, currentSearchKey, currentSearchQueryJSON} = useSearchQueryContext(); const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, true, areAllMatchingItemsSelected); const {isOffline} = useNetwork(); @@ -325,7 +326,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { // 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 = (isExpenseType || isReportsSearch) && !hasGroupSelection; + const shouldShowCountSelector = (isExpenseType || isReportsSearch) && !hasGroupSelection && (hasPartialSelection || (!shouldUseLiveData && typeof metadataReportCount === 'number')); const footerCountType = shouldShowCountSelector ? (footerSelection.footerCount ?? defaultFooterCountType) : undefined; // An empty result set has no total to break down, so it keeps the plain total spend with no selector. @@ -556,6 +557,10 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { } const nextQuery = getQueryWithFooterSelection(currentSearchQueryJSON, selection); + // `footerTotal` moves the search hash, so the new query has no snapshot of its own yet. Marking it a + // refinement is what keeps the rows, the selection and the figures on screen while it loads, instead of + // unmounting the list to a skeleton over a change that matches exactly the same rows. + markQueryAsRefinement(nextQuery); close(() => { Navigation.setParams({q: nextQuery, rawQuery: undefined}); }); diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 00d3c3efd8ce..8171dc8d31f2 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -22,6 +22,7 @@ import {clearFooterConversion, search} from '@libs/actions/Search'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; import {isQueryARefinement} from '@libs/SearchQueryRefinement'; +import {getQueryHashWithoutFooterSelections} from '@libs/SearchQueryUtils'; import {isSearchDataLoaded} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; @@ -121,7 +122,13 @@ function SearchPage({route}: SearchPageProps) { // query would mount it with no data and flash a skeleton in the middle of the fade. Key it on the last query // that actually resolved instead: the current results stay on screen until the new ones arrive, then the area // swaps once. - const isSearchResolvedForCurrentQuery = isCurrentSearchResolved && !!searchResults && !!currentSearchQueryJSON; + // `state: loaded` lands in the snapshot before the rows do — the client writes that state when the request + // settles, and the response's own data is a separate Onyx merge. Ending the hold on it hands the results area a + // row-less snapshot for a render, which reads as an emptied result: the list swaps out and the selection + // reconcile wipes what was selected. A snapshot the server described as empty (`hasResults: false`) is a real + // answer and resolves immediately. + const hasRowsForCurrentQuery = currentSearchResults?.data !== undefined || currentSearchResults?.search?.hasResults === false || !!currentSearchResults?.errors; + const isSearchResolvedForCurrentQuery = isCurrentSearchResolved && hasRowsForCurrentQuery && !!searchResults && !!currentSearchQueryJSON; if (isSearchResolvedForCurrentQuery && currentSearchQueryJSON && searchResults && lastResolvedSearch?.searchResults !== searchResults) { setLastResolvedSearch({queryJSON: currentSearchQueryJSON, searchResults}); } @@ -141,9 +148,17 @@ function SearchPage({route}: SearchPageProps) { const hasStaleHoldTimedOut = staleHoldTimedOutHash !== undefined && staleHoldTimedOutHash === currentQueryHash; - // 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); + // A Spend footer selection asks the backend for a different aggregate over the very same rows, so the results area + // holds what is on screen for as long as that search takes: the rows cannot change, and letting the hold lapse + // would unmount the list and take the selection with it. + const isFooterSelectionChangeOnly = + !!currentSearchQueryJSON && !!lastResolvedSearch && getQueryHashWithoutFooterSelections(currentSearchQueryJSON) === getQueryHashWithoutFooterSelections(lastResolvedSearch.queryJSON); + + // Otherwise only a filter refinement is worth holding for, and only briefly. 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 && (isFooterSelectionChangeOnly || (!hasStaleHoldTimedOut && isQueryARefinement(currentSearchQueryJSON?.inputQuery))); const contentQueryJSON = shouldHoldLastResolvedSearch ? lastResolvedSearch.queryJSON : currentSearchQueryJSON; const contentSearchResults = shouldHoldLastResolvedSearch ? lastResolvedSearch.searchResults : searchResults; diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 1b667f216b57..777dc598b3fc 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -390,7 +390,7 @@ function SearchPageNarrow({ )} - + diff --git a/src/pages/Search/SearchPageWide.tsx b/src/pages/Search/SearchPageWide.tsx index d9ae683bd549..78f5f608d03f 100644 --- a/src/pages/Search/SearchPageWide.tsx +++ b/src/pages/Search/SearchPageWide.tsx @@ -168,7 +168,7 @@ function SearchPageWide({ {/* Floats over the bottom of the list, which already ends above SearchSelectionFooter. */} - + )} diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx index 0eee0b47363e..8bd30dc60165 100644 --- a/tests/unit/Search/SearchSelectionFooterTest.tsx +++ b/tests/unit/Search/SearchSelectionFooterTest.tsx @@ -230,7 +230,7 @@ describe('SearchSelectionFooter', () => { }); it('shows the authoritative expense count and total before every report page is loaded', async () => { - setSearchQuery('type:expense-report footerCount:expenses'); + setSearchQuery('type:expense-report'); mockSelectedTransactions.current = {transaction1: buildSelectedTransaction(CONST.CURRENCY.USD, undefined, -100, 'report1')}; mockSelectedReports.current = [buildSelectedReport('report1', -100)]; mockAreAllMatchingItemsSelected.current = true; @@ -715,16 +715,16 @@ describe('SearchSelectionFooter', () => { expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.EXPENSES, count: 2})); }); - it('offers both selectors on a grouped search with nothing selected, counting expenses', async () => { + 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).toEqual( - expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.EXPENSES, totalType: CONST.SEARCH.FOOTER_TOTAL.TOTAL, count: 1204, total: 36000}), - ); + 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 () => { @@ -857,13 +857,13 @@ describe('SearchSelectionFooter', () => { expect(mockSetParams.mock.calls.at(0)?.at(0)?.q).toContain('footerCount:reports'); }); - it('keeps the count on expenses when the search returned no report count', async () => { + it('offers no count selector when the search returned no report count', async () => { mockSelectedTransactions.current = {}; render(); await waitForBatchedUpdates(); - expect(mockCapturedFooterProps.current?.countType).toBe(CONST.SEARCH.FOOTER_COUNT.EXPENSES); + expect(mockCapturedFooterProps.current?.countType).toBeUndefined(); expect(mockCapturedFooterProps.current?.count).toBe(1204); }); }); From bdd82b047c0882e553ee0a1d51b2f5e830f3a0d6 Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 29 Sep 2026 11:11:52 +0700 Subject: [PATCH 44/86] fix: stop a Spend footer selection from making a search look filtered --- src/libs/SearchQueryUtils.ts | 8 ++++++-- src/libs/SearchUIUtils.ts | 3 +++ src/libs/actions/IOU/SearchUpdate.ts | 15 +++++++++++---- tests/unit/Search/SearchQueryUtilsTest.ts | 16 ++++++++++++++++ 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index ef3ed88800ad..e6626c5caf47 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -2566,12 +2566,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; } /** diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index fcd0b11fcc65..d8fd1a22a7de 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -659,6 +659,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, ]); function doesSearchItemMatchSort(key: SearchKey, itemSortBy: string | undefined, itemSortOrder: string | undefined, currentSortBy: string | undefined, currentSortOrder: string | undefined) { diff --git a/src/libs/actions/IOU/SearchUpdate.ts b/src/libs/actions/IOU/SearchUpdate.ts index 3e3ef95cf906..142ed152ad44 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 {isExpenseReport, isOptimisticPersonalDetail} from '@libs/ReportUtils'; import {buildCannedSearchQuery, buildSearchQueryJSON, buildSearchQueryString, getCurrentSearchQueryJSON, getFilterFromQuery} from '@libs/SearchQueryUtils'; @@ -96,9 +96,16 @@ function shouldOptimisticallyUpdateSearch( // `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, - ); + // The Spend footer's selections restrict nothing either — they pick which figures the footer prints — so a new expense + // still belongs on the list, which matters most offline, where the server's answer never comes. + const NON_RESTRICTIVE_FILTER_KEYS = new Set([ + CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS, + CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID, + CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_COUNT, + CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_TOTAL, + CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_CURRENCY, + ]); + const hasNoFlatFilters = currentSearchQueryJSON.flatFilters.every((filter) => NON_RESTRICTIVE_FILTER_KEYS.has(filter.key)); const onlyFromFilter = currentSearchQueryJSON.flatFilters.length === 1 ? currentSearchQueryJSON.flatFilters.at(0) : undefined; const matchesFromQuery: boolean = diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index 0e38806c7466..12dfaf7b0a74 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -2618,6 +2618,22 @@ describe('SearchQueryUtils', () => { 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'); From 1371ca6400943ba7a3f2358fa5f582938675c893 Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 29 Sep 2026 11:18:15 +0700 Subject: [PATCH 45/86] fix: leave the default footer total out of the search query --- src/libs/SearchQueryUtils.ts | 9 +++++++-- tests/unit/Search/SearchQueryUtilsTest.ts | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index e6626c5caf47..6ae2ce69e892 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -726,7 +726,10 @@ function getQueryWithFooterSelection( [CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_TOTAL, nextSelection.footerTotal], [CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_CURRENCY, nextSelection.footerCurrency], ] as const) { - if (!value) { + // The plain total is what a query with no selection already shows, and it is the one selection that keys the + // snapshot: writing it would send the user back to a hash they came from as if it were a different search, + // costing a request for figures already cached under the query they started on. + if (!value || (key === CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_TOTAL && value === CONST.SEARCH.FOOTER_TOTAL.TOTAL)) { continue; } flatFilters.push({key, filters: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value}]}); @@ -979,8 +982,10 @@ function getQueryHashes(query: SearchQueryJSON) { // The total the footer asks for changes the aggregate the backend answers with, so it is part of the primary // hash — the snapshot key. It joins here, after the recent and similar hashes are taken, so switching the // breakdown is still the same search in the recent list and still matches the same saved search. + // The plain total is what the backend answers without being asked, so a query carrying it explicitly — a saved + // search or a hand-typed link — keys the same snapshot as one that leaves it out. const {footerTotal} = getFooterSelectionFromQuery(query); - if (footerTotal) { + if (footerTotal && footerTotal !== CONST.SEARCH.FOOTER_TOTAL.TOTAL) { orderedQuery += ` ${CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_TOTAL}:${footerTotal}`; } diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index 12dfaf7b0a74..86ad27f6edb9 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -25,6 +25,7 @@ import { getFilterFormValues, getFilterFromQuery, getFooterSelectionFromQuery, + getQueryWithFooterSelection, getQueryHashWithoutFooterSelections, queryHasViolationFilter, hasValuesIncludeViolationFilter, @@ -2586,6 +2587,24 @@ describe('SearchQueryUtils', () => { expect(expenses?.similarSearchHash).toEqual(noSelection?.similarSearchHash); }); + it('leaves the plain total out of the query, so switching back to it returns to the search it came from', () => { + 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, and the hash is the one the untouched search has. + expect(backToPlainTotal).toContain('footerCurrency:EUR'); + expect(buildSearchQueryJSON(backToPlainTotal)?.hash).toBe(buildSearchQueryJSON('type:expense')?.hash); + }); + + it('hashes a query that spells out the plain total like one that leaves it out, so a saved search keys one snapshot', () => { + expect(buildSearchQueryJSON('type:expense footerTotal:total')?.hash).toBe(buildSearchQueryJSON('type:expense')?.hash); + }); + it('moves the primary hash for the footer total, which is the one selection the backend answers differently', () => { const noSelection = buildSearchQueryJSON('type:expense'); const total = buildSearchQueryJSON('type:expense footerTotal:total'); From e55e2a46a85922143b3883481c55a0ec2155ab97 Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 29 Sep 2026 11:49:24 +0700 Subject: [PATCH 46/86] fix: test --- src/pages/Search/SearchPage.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 8171dc8d31f2..7e02804f1d77 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -151,8 +151,14 @@ function SearchPage({route}: SearchPageProps) { // A Spend footer selection asks the backend for a different aggregate over the very same rows, so the results area // holds what is on screen for as long as that search takes: the rows cannot change, and letting the hold lapse // would unmount the list and take the selection with it. + // Only across a *different* query: the same one un-resolving is a reload of what is on screen (an error being + // cleared so it can be requested again, say), and holding the last results through that would pin whatever they + // carried, error included. const isFooterSelectionChangeOnly = - !!currentSearchQueryJSON && !!lastResolvedSearch && getQueryHashWithoutFooterSelections(currentSearchQueryJSON) === getQueryHashWithoutFooterSelections(lastResolvedSearch.queryJSON); + !!currentSearchQueryJSON && + !!lastResolvedSearch && + currentSearchQueryJSON.hash !== lastResolvedSearch.queryJSON.hash && + getQueryHashWithoutFooterSelections(currentSearchQueryJSON) === getQueryHashWithoutFooterSelections(lastResolvedSearch.queryJSON); // Otherwise only a filter refinement is worth holding for, and only briefly. 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 From f51b7a3f637d8b8956b9f5b49b270c96f9ed725d Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 29 Sep 2026 23:19:25 +0700 Subject: [PATCH 47/86] feat: add beta flag --- src/CONST/index.ts | 1 + .../Search/SearchSelectionFooter.tsx | 13 ++++-- .../unit/Search/SearchSelectionFooterTest.tsx | 44 +++++++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 9b8c62a508eb..352a134c1ed5 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1143,6 +1143,7 @@ const CONST = { INSIGHTS_PAGE: 'insightsPage', INSIGHTS_COMPARE: 'insightsCompare', PAYMENT_HISTORY: 'paymentHistory', + SPEND_FOOTER_SELECTORS: 'spendFooterSelectors', }, BUTTON_STATES: { DEFAULT: 'default', diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index 7bdb9e6a30d2..546dbb166c5d 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -1,6 +1,7 @@ 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 {close} from '@libs/actions/Modal'; @@ -40,6 +41,7 @@ type FooterCurrencyState = { defaultCurrency: string | undefined; }; +const EMPTY_FOOTER_SELECTION = {footerCount: undefined, footerTotal: undefined, footerCurrency: undefined}; const EMPTY_REPORT_IDS: string[] = []; const EMPTY_SOURCES: Record = {}; @@ -128,7 +130,9 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { // changes the figure on display, stamped with the snapshot hash the request was made from. The stamp ends the wait // on its own once the snapshot moves off it, so a later visit to that hash never skeletons the total again. const [pendingTotal, setPendingTotal] = useState<{hash: number | undefined; fromHash: number | undefined}>(); - const footerSelection = getFooterSelectionFromQuery(currentSearchQueryJSON); + 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>>({}); @@ -326,11 +330,12 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { // 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 = (isExpenseType || isReportsSearch) && !hasGroupSelection && (hasPartialSelection || (!shouldUseLiveData && typeof metadataReportCount === 'number')); + const shouldShowCountSelector = + isFooterSelectorsEnabled && (isExpenseType || isReportsSearch) && !hasGroupSelection && (hasPartialSelection || (!shouldUseLiveData && typeof metadataReportCount === 'number')); const footerCountType = shouldShowCountSelector ? (footerSelection.footerCount ?? defaultFooterCountType) : undefined; // An empty result set has no total to break down, so it keeps the plain total spend with no selector. - const shouldShowTotalSelector = !hasGroupSelection && (!!metadataCount || selectedTransactionsKeys.length > 0); + const shouldShowTotalSelector = isFooterSelectorsEnabled && !hasGroupSelection && (!!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. @@ -552,7 +557,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { // 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) { + if (!currentSearchQueryJSON || !isFooterSelectorsEnabled) { return; } diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx index 8bd30dc60165..7db4749b2a73 100644 --- a/tests/unit/Search/SearchSelectionFooterTest.tsx +++ b/tests/unit/Search/SearchSelectionFooterTest.tsx @@ -19,6 +19,15 @@ jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); 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, + }), +})); + jest.mock('@libs/actions/Search', () => ({ getFooterConvertedAmounts: jest.fn(), search: jest.fn(), @@ -182,6 +191,7 @@ describe('SearchSelectionFooter', () => { }); beforeEach(async () => { + mockIsFooterSelectorsBetaEnabled.current = true; setSearchQuery('type:expense'); mockSelectedTransactions.current = {transaction1: buildSelectedTransaction(SELECTED_EXPENSE_CURRENCY)}; mockExcludedTransactions.current = {}; @@ -866,5 +876,39 @@ describe('SearchSelectionFooter', () => { 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(); + }); + }); }); }); From 949c9d1140983de505780ac3bc075f2b56847d14 Mon Sep 17 00:00:00 2001 From: truph01 Date: Thu, 1 Oct 2026 10:25:54 +0700 Subject: [PATCH 48/86] fix: conflicts --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index a23d96e57fdf..7fe6e6a7ab28 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit a23d96e57fdf48d7c7b00edeb76ed90c7b4afa2d +Subproject commit 7fe6e6a7ab289420fc949e1d499f5f3610ec22a1 From 1241b3ca87c69ae6d68571694b020aeccfce833e Mon Sep 17 00:00:00 2001 From: truph01 Date: Thu, 1 Oct 2026 13:54:50 +0700 Subject: [PATCH 49/86] fix: keep the search selection across a Spend footer total switch --- src/components/Search/index.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index d51b2e0c2487..ecdf854c1826 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -36,7 +36,7 @@ import {flushPendingSearchWrite, hasPendingSearchWrite} from '@libs/pendingSearc import {isCreatedTaskReportAction} from '@libs/ReportActionsUtils'; import {isOneTransactionReport} from '@libs/ReportUtils'; import {searchKeyToSavedSearchID} from '@libs/SearchKeyUtils'; -import {buildCannedSearchQuery, buildSearchQueryString} from '@libs/SearchQueryUtils'; +import {buildCannedSearchQuery, buildSearchQueryString, getQueryHashWithoutFooterSelections} from '@libs/SearchQueryUtils'; import { createAndOpenSearchTransactionThread, doesSearchItemMatchSort, @@ -156,7 +156,7 @@ function Search({ const isFocused = useIsFocused(); const {markReportRHPWidth, unmarkReportRHPWidth} = useWideRHPActions(); - const {currentSearchHash, currentSearchKey, shouldResetSearchQuery, suggestedSearches} = useSearchQueryContext(); + const {currentSearchHash, currentSearchQueryJSON, currentSearchKey, shouldResetSearchQuery, suggestedSearches} = useSearchQueryContext(); const {lastSearchType, shouldUseLiveData} = useSearchResultsContext(); const {setShouldResetSearchQuery} = useSearchQueryActions(); @@ -564,6 +564,9 @@ function Search({ clearSelectedTransactions(); }, [isSearchResultsEmpty, prevIsSearchResultEmpty, clearSelectedTransactions]); + const hashWithoutFooterSelections = getQueryHashWithoutFooterSelections(queryJSON); + const currentHashWithoutFooterSelections = currentSearchQueryJSON ? getQueryHashWithoutFooterSelections(currentSearchQueryJSON) : currentSearchHash; + const isUnmounted = useRef(false); const hasHadFirstLayout = useRef(false); const navigateToReportsSpanOnMount = useRef(getSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS)); @@ -591,13 +594,13 @@ function Search({ if (!isFocused && !isUnmounted.current) { return; } - if (isSearchTopmostFullScreenRoute() && currentSearchHash === hash) { + if (isSearchTopmostFullScreenRoute() && hashWithoutFooterSelections === currentHashWithoutFooterSelections) { return; } clearSelectedTransactions(); turnOffMobileSelectionMode(); }, - [isFocused, clearSelectedTransactions, hash, currentSearchHash], + [isFocused, clearSelectedTransactions, hashWithoutFooterSelections, currentHashWithoutFooterSelections], ); const areItemsGrouped = !!validGroupBy || isExpenseReportType; From 794570aabda6c969ee1d5117645aaf8d887428cc Mon Sep 17 00:00:00 2001 From: truph01 Date: Fri, 2 Oct 2026 04:49:27 +0700 Subject: [PATCH 50/86] fix: stop the Spend footer total from skeletoning forever on a to-do search --- .../Search/SearchSelectionFooter.tsx | 9 +++--- .../unit/Search/SearchSelectionFooterTest.tsx | 31 ++++++++++++++++++- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index 546dbb166c5d..a7c92a163b5f 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -582,11 +582,12 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { const handleFooterTotalChange = (nextTotalType: SearchFooterTotal) => { // A hand-picked selection is summed from its own rows, so it shows the new breakdown straight away and waits - // on nothing. Every other case needs the figure from the backend, so the skeleton waits on the hash the query - // is moving to — `footerTotal` is part of the hash, since it changes the aggregate that comes back. + // on nothing. The same goes for a to-do search, whose totals are summed from live Onyx data with the query's + // own breakdown applied. Every other case needs the figure from the backend, so the skeleton waits on the hash + // the query is moving to — `footerTotal` is part of the hash, since it changes the aggregate that comes back. if (hasPartialSelection) { setFooterTotalState({searchHash: currentSearchHash, selectedTotal: nextTotalType}); - } else if (currentSearchQueryJSON) { + } else if (currentSearchQueryJSON && !shouldUseLiveData) { setPendingTotal({hash: buildSearchQueryJSON(getQueryWithFooterSelection(currentSearchQueryJSON, {footerTotal: nextTotalType}))?.hash, fromHash: metadata?.hash}); } @@ -717,7 +718,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { } const isAwaitingFooterTotal = - pendingTotal?.hash !== undefined && pendingTotal.hash === currentSearchHash && metadata?.hash !== currentSearchHash && metadata?.hash === pendingTotal.fromHash; + !shouldUseLiveData && pendingTotal?.hash !== undefined && pendingTotal.hash === currentSearchHash && metadata?.hash !== currentSearchHash && metadata?.hash === pendingTotal.fromHash; // A partial selection shows a client-side subtotal that is ready immediately, so it never waits on a search. const isFooterTotalLoading = isFooterTotalConverting || (!hasPartialSelection && (isAwaitingFooterTotal || (!!metadata?.isLoading && metadata?.offset === 0))); diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx index 7db4749b2a73..2bb0c7e326e3 100644 --- a/tests/unit/Search/SearchSelectionFooterTest.tsx +++ b/tests/unit/Search/SearchSelectionFooterTest.tsx @@ -65,9 +65,10 @@ const mockSelectedTransactions: {current: SelectedTransactions} = {current: {}}; const mockExcludedTransactions: {current: SelectedTransactions} = {current: {}}; const mockSelectedReports: {current: SelectedReports[]} = {current: []}; const mockAreAllMatchingItemsSelected = {current: false}; +const mockShouldUseLiveData = {current: false}; jest.mock('@components/Search/SearchContext', () => ({ useSearchQueryContext: () => mockSearchQueryContext.current, - useSearchResultsContext: () => ({currentSearchResults: undefined}), + useSearchResultsContext: () => ({currentSearchResults: undefined, shouldUseLiveData: mockShouldUseLiveData.current}), useSearchSelectionContext: () => ({ selectedTransactions: mockSelectedTransactions.current, excludedTransactions: mockExcludedTransactions.current, @@ -192,6 +193,7 @@ describe('SearchSelectionFooter', () => { beforeEach(async () => { mockIsFooterSelectorsBetaEnabled.current = true; + mockShouldUseLiveData.current = false; setSearchQuery('type:expense'); mockSelectedTransactions.current = {transaction1: buildSelectedTransaction(SELECTED_EXPENSE_CURRENCY)}; mockExcludedTransactions.current = {}; @@ -500,6 +502,33 @@ describe('SearchSelectionFooter', () => { expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({isTotalLoading: false, total: 12000})); }); + 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); From 9c56336b2d022f75bdf0349ad27976970362259d Mon Sep 17 00:00:00 2001 From: truph01 Date: Fri, 2 Oct 2026 04:52:04 +0700 Subject: [PATCH 51/86] fix: remove redundant check --- src/pages/Search/SearchPage.tsx | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 7e02804f1d77..9f59afa2aa96 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -22,7 +22,6 @@ import {clearFooterConversion, search} from '@libs/actions/Search'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; import {isQueryARefinement} from '@libs/SearchQueryRefinement'; -import {getQueryHashWithoutFooterSelections} from '@libs/SearchQueryUtils'; import {isSearchDataLoaded} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; @@ -148,23 +147,10 @@ function SearchPage({route}: SearchPageProps) { const hasStaleHoldTimedOut = staleHoldTimedOutHash !== undefined && staleHoldTimedOutHash === currentQueryHash; - // A Spend footer selection asks the backend for a different aggregate over the very same rows, so the results area - // holds what is on screen for as long as that search takes: the rows cannot change, and letting the hold lapse - // would unmount the list and take the selection with it. - // Only across a *different* query: the same one un-resolving is a reload of what is on screen (an error being - // cleared so it can be requested again, say), and holding the last results through that would pin whatever they - // carried, error included. - const isFooterSelectionChangeOnly = - !!currentSearchQueryJSON && - !!lastResolvedSearch && - currentSearchQueryJSON.hash !== lastResolvedSearch.queryJSON.hash && - getQueryHashWithoutFooterSelections(currentSearchQueryJSON) === getQueryHashWithoutFooterSelections(lastResolvedSearch.queryJSON); - // Otherwise only a filter refinement is worth holding for, and only briefly. 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 && (isFooterSelectionChangeOnly || (!hasStaleHoldTimedOut && isQueryARefinement(currentSearchQueryJSON?.inputQuery))); + const shouldHoldLastResolvedSearch = !isSearchResolvedForCurrentQuery && !!lastResolvedSearch && !hasStaleHoldTimedOut && isQueryARefinement(currentSearchQueryJSON?.inputQuery); const contentQueryJSON = shouldHoldLastResolvedSearch ? lastResolvedSearch.queryJSON : currentSearchQueryJSON; const contentSearchResults = shouldHoldLastResolvedSearch ? lastResolvedSearch.searchResults : searchResults; From 691aa900b8b3e4bec2cc256a4a73bcf071316ac5 Mon Sep 17 00:00:00 2001 From: truph01 Date: Fri, 2 Oct 2026 04:52:57 +0700 Subject: [PATCH 52/86] fix: comment --- src/pages/Search/SearchPage.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 9f59afa2aa96..ccee7a20a9ef 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -147,9 +147,8 @@ function SearchPage({route}: SearchPageProps) { const hasStaleHoldTimedOut = staleHoldTimedOutHash !== undefined && staleHoldTimedOutHash === currentQueryHash; - // Otherwise only a filter refinement is worth holding for, and only briefly. 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. + // 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 contentQueryJSON = shouldHoldLastResolvedSearch ? lastResolvedSearch.queryJSON : currentSearchQueryJSON; const contentSearchResults = shouldHoldLastResolvedSearch ? lastResolvedSearch.searchResults : searchResults; From b92eb53dd7598811ed8ed69849b69ce570f01085 Mon Sep 17 00:00:00 2001 From: truph01 Date: Fri, 2 Oct 2026 04:56:46 +0700 Subject: [PATCH 53/86] fix: remove redundant changes in SearchPage --- src/pages/Search/SearchPage.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index ccee7a20a9ef..00d3c3efd8ce 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -121,13 +121,7 @@ function SearchPage({route}: SearchPageProps) { // query would mount it with no data and flash a skeleton in the middle of the fade. Key it on the last query // that actually resolved instead: the current results stay on screen until the new ones arrive, then the area // swaps once. - // `state: loaded` lands in the snapshot before the rows do — the client writes that state when the request - // settles, and the response's own data is a separate Onyx merge. Ending the hold on it hands the results area a - // row-less snapshot for a render, which reads as an emptied result: the list swaps out and the selection - // reconcile wipes what was selected. A snapshot the server described as empty (`hasResults: false`) is a real - // answer and resolves immediately. - const hasRowsForCurrentQuery = currentSearchResults?.data !== undefined || currentSearchResults?.search?.hasResults === false || !!currentSearchResults?.errors; - const isSearchResolvedForCurrentQuery = isCurrentSearchResolved && hasRowsForCurrentQuery && !!searchResults && !!currentSearchQueryJSON; + const isSearchResolvedForCurrentQuery = isCurrentSearchResolved && !!searchResults && !!currentSearchQueryJSON; if (isSearchResolvedForCurrentQuery && currentSearchQueryJSON && searchResults && lastResolvedSearch?.searchResults !== searchResults) { setLastResolvedSearch({queryJSON: currentSearchQueryJSON, searchResults}); } From 1763e75495bba2f48ca45e38e1d6aa0105e96517 Mon Sep 17 00:00:00 2001 From: truph01 Date: Fri, 2 Oct 2026 05:23:33 +0700 Subject: [PATCH 54/86] fix: revert solution --- src/pages/Search/SearchPage.tsx | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 00d3c3efd8ce..7e02804f1d77 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -22,6 +22,7 @@ import {clearFooterConversion, search} from '@libs/actions/Search'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; import {isQueryARefinement} from '@libs/SearchQueryRefinement'; +import {getQueryHashWithoutFooterSelections} from '@libs/SearchQueryUtils'; import {isSearchDataLoaded} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; @@ -121,7 +122,13 @@ function SearchPage({route}: SearchPageProps) { // query would mount it with no data and flash a skeleton in the middle of the fade. Key it on the last query // that actually resolved instead: the current results stay on screen until the new ones arrive, then the area // swaps once. - const isSearchResolvedForCurrentQuery = isCurrentSearchResolved && !!searchResults && !!currentSearchQueryJSON; + // `state: loaded` lands in the snapshot before the rows do — the client writes that state when the request + // settles, and the response's own data is a separate Onyx merge. Ending the hold on it hands the results area a + // row-less snapshot for a render, which reads as an emptied result: the list swaps out and the selection + // reconcile wipes what was selected. A snapshot the server described as empty (`hasResults: false`) is a real + // answer and resolves immediately. + const hasRowsForCurrentQuery = currentSearchResults?.data !== undefined || currentSearchResults?.search?.hasResults === false || !!currentSearchResults?.errors; + const isSearchResolvedForCurrentQuery = isCurrentSearchResolved && hasRowsForCurrentQuery && !!searchResults && !!currentSearchQueryJSON; if (isSearchResolvedForCurrentQuery && currentSearchQueryJSON && searchResults && lastResolvedSearch?.searchResults !== searchResults) { setLastResolvedSearch({queryJSON: currentSearchQueryJSON, searchResults}); } @@ -141,9 +148,23 @@ function SearchPage({route}: SearchPageProps) { const hasStaleHoldTimedOut = staleHoldTimedOutHash !== undefined && staleHoldTimedOutHash === currentQueryHash; - // 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); + // A Spend footer selection asks the backend for a different aggregate over the very same rows, so the results area + // holds what is on screen for as long as that search takes: the rows cannot change, and letting the hold lapse + // would unmount the list and take the selection with it. + // Only across a *different* query: the same one un-resolving is a reload of what is on screen (an error being + // cleared so it can be requested again, say), and holding the last results through that would pin whatever they + // carried, error included. + const isFooterSelectionChangeOnly = + !!currentSearchQueryJSON && + !!lastResolvedSearch && + currentSearchQueryJSON.hash !== lastResolvedSearch.queryJSON.hash && + getQueryHashWithoutFooterSelections(currentSearchQueryJSON) === getQueryHashWithoutFooterSelections(lastResolvedSearch.queryJSON); + + // Otherwise only a filter refinement is worth holding for, and only briefly. 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 && (isFooterSelectionChangeOnly || (!hasStaleHoldTimedOut && isQueryARefinement(currentSearchQueryJSON?.inputQuery))); const contentQueryJSON = shouldHoldLastResolvedSearch ? lastResolvedSearch.queryJSON : currentSearchQueryJSON; const contentSearchResults = shouldHoldLastResolvedSearch ? lastResolvedSearch.searchResults : searchResults; From b989951ee5cc51d9e2c6098c17c5e8faa51ce21f Mon Sep 17 00:00:00 2001 From: truph01 Date: Fri, 2 Oct 2026 16:04:21 +0700 Subject: [PATCH 55/86] fix: don't hold another search's results on a to-do search --- src/pages/Search/SearchPage.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 7e02804f1d77..7fc90b4aa836 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -164,7 +164,10 @@ function SearchPage({route}: SearchPageProps) { // 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 && (isFooterSelectionChangeOnly || (!hasStaleHoldTimedOut && isQueryARefinement(currentSearchQueryJSON?.inputQuery))); + !shouldUseLiveData && + !isSearchResolvedForCurrentQuery && + !!lastResolvedSearch && + (isFooterSelectionChangeOnly || (!hasStaleHoldTimedOut && isQueryARefinement(currentSearchQueryJSON?.inputQuery))); const contentQueryJSON = shouldHoldLastResolvedSearch ? lastResolvedSearch.queryJSON : currentSearchQueryJSON; const contentSearchResults = shouldHoldLastResolvedSearch ? lastResolvedSearch.searchResults : searchResults; From cbf34dd762f9a28d23a786f4d6bf2f681d1f28c3 Mon Sep 17 00:00:00 2001 From: truph01 Date: Fri, 2 Oct 2026 17:02:37 +0700 Subject: [PATCH 56/86] fix: selected rows disappear when togging footerTotal --- .../Search/SearchWriteActionsProvider.tsx | 33 +++ src/components/Search/index.tsx | 1 + .../FooterTotalSelectionRetentionTest.tsx | 221 ++++++++++++++++++ tests/unit/Search/LazyGroupSelectionTest.tsx | 10 + 4 files changed, 265 insertions(+) create mode 100644 tests/unit/Search/FooterTotalSelectionRetentionTest.tsx diff --git a/src/components/Search/SearchWriteActionsProvider.tsx b/src/components/Search/SearchWriteActionsProvider.tsx index 7c282c7b0605..1f179e92ae5c 100644 --- a/src/components/Search/SearchWriteActionsProvider.tsx +++ b/src/components/Search/SearchWriteActionsProvider.tsx @@ -68,6 +68,9 @@ type SearchWriteActionsProviderProps = { /** Everything scoped to one search is keyed on it */ searchHash: number; + /** The same hash with the Spend footer's own selections left out, so a footer switch can be told from a real query change */ + searchHashWithoutFooterSelections: number; + /** The live TRANSACTION collection, subscribed by `` and passed down. */ transactions: OnyxCollection; @@ -125,6 +128,12 @@ type ReconcileSelectionParams = { /** Whether the current snapshot is settled and can safely refresh/prune exclusions */ shouldReconcileExcludedTransactions: boolean; + + /** The search whose rows are being reconciled against */ + searchHash: number; + + /** @see SearchWriteActionsProviderProps['searchHashWithoutFooterSelections'] */ + searchHashWithoutFooterSelections: number; }; /** @@ -148,12 +157,23 @@ function useReconcileSelectionWithData({ reportNameValuePairs, outstandingReportsByPolicyID, shouldReconcileExcludedTransactions, + searchHash, + searchHashWithoutFooterSelections, }: ReconcileSelectionParams) { const {selectedTransactions, excludedTransactions = getEmptyObject(), areAllMatchingItemsSelected} = useSearchSelectionContext(); const {applySelection} = useSearchSelectionActions(); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const previousSearchRef = useRef({hash: searchHash, footerlessHash: searchHashWithoutFooterSelections}); + const footerSwitchedToHashRef = useRef(undefined); useEffect(() => { + const previousSearch = previousSearchRef.current; + if (searchHash !== previousSearch.hash) { + footerSwitchedToHashRef.current = searchHashWithoutFooterSelections === previousSearch.footerlessHash ? searchHash : undefined; + previousSearchRef.current = {hash: searchHash, footerlessHash: searchHashWithoutFooterSelections}; + } + const shouldKeepUnloadedSelection = footerSwitchedToHashRef.current === searchHash; + const shouldReconcileMovedExcludedTransaction = isExpenseReportType && areAllMatchingItemsSelected && @@ -378,6 +398,16 @@ function useReconcileSelectionWithData({ } } + // Rows the footer-switched search has not loaded back yet keep the selection they had. + if (shouldKeepUnloadedSelection) { + for (const [key, selectedTransaction] of Object.entries(selectedTransactions)) { + if (Object.hasOwn(newTransactionList, key) || Object.hasOwn(excludedTransactions, key)) { + continue; + } + newTransactionList[key] = selectedTransaction; + } + } + // A lazy group's children never reach `filteredData`, so the group's presence is what keeps them. if (areItemsGrouped) { for (const [key, selectedTransaction] of Object.entries(selectedTransactions)) { @@ -522,6 +552,7 @@ function SearchWriteActionsProvider({ totalSelectableItemsCount, searchResults, searchHash, + searchHashWithoutFooterSelections, transactions, isMobileSelectionModeEnabled, type, @@ -930,6 +961,8 @@ function SearchWriteActionsProvider({ outstandingReportsByPolicyID, shouldReconcileExcludedTransactions: (type === CONST.SEARCH.DATA_TYPES.EXPENSE || isExpenseReportType) && !!searchResultsData && searchResults?.search?.isLoading === false && !searchResults?.errors, + searchHash, + searchHashWithoutFooterSelections, }); useTurnOffSelectionModeWhenEmpty({isFocused, isMobileSelectionModeEnabled}); useSyncMobileSelectionModeWithScreenSize({isFocused, isMobileSelectionModeEnabled, isSearchResultsEmpty}); diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index ecdf854c1826..f90e9142819a 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1460,6 +1460,7 @@ function Search({ totalSelectableItemsCount={totalSelectableItemsCount} searchResults={searchResults} searchHash={hash} + searchHashWithoutFooterSelections={hashWithoutFooterSelections} transactions={transactions} isMobileSelectionModeEnabled={isMobileSelectionModeEnabled} type={type} diff --git a/tests/unit/Search/FooterTotalSelectionRetentionTest.tsx b/tests/unit/Search/FooterTotalSelectionRetentionTest.tsx new file mode 100644 index 000000000000..9d4186f6bf9a --- /dev/null +++ b/tests/unit/Search/FooterTotalSelectionRetentionTest.tsx @@ -0,0 +1,221 @@ +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; + +/** The hash a Spend footer total switch moves the query onto: the same rows, a different aggregate. */ +const FOOTER_TOTAL_HASH = 2; + +/** A real filter change, which lands on a different search altogether. */ +const OTHER_SEARCH_HASH = 3; + +/** What the two hashes above share: the query with the footer's own selections left out. */ +const FOOTERLESS_HASH = 10; +const OTHER_FOOTERLESS_HASH = 11; + +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; +let renderedFooterlessHash = FOOTERLESS_HASH; + +function Wrapper({children}: {children: React.ReactNode}) { + return ( + + + {children} + + + ); +} + +const renderSelection = () => + renderHook( + () => ({ + ...useSearchSelectionContext(), + ...useSearchSelectionActions(), + ...useSearchRowSelectionActions(), + }), + {wrapper: Wrapper}, + ); + +describe('Selection across a Spend footer total switch', () => { + beforeAll(() => Onyx.init({keys: ONYXKEYS})); + + beforeEach(() => { + renderedRows = bothPages; + renderedHash = SEARCH_HASH; + renderedFooterlessHash = FOOTERLESS_HASH; + }); + + afterEach(async () => { + await Onyx.clear(); + }); + + it('keeps rows selected beyond the first page, which the re-run has not loaded back yet', async () => { + // Given two rows selected from a page that loaded after the first + const {result, rerender} = renderSelection(); + await act(async () => { + result.current.toggle(laterPageRowA); + result.current.toggle(laterPageRowB); + await waitForBatchedUpdatesWithAct(); + }); + expect(Object.keys(result.current.selectedTransactions).sort()).toEqual(['3', '4']); + + // When a footer total switch moves the query onto its own hash, re-running the search from the first page + renderedRows = firstPage; + renderedHash = FOOTER_TOTAL_HASH; + rerender({}); + await waitForBatchedUpdatesWithAct(); + + // Then they stay selected: the rows are unloaded, not gone + expect(Object.keys(result.current.selectedTransactions).sort()).toEqual(['3', '4']); + }); + + it('keeps both halves of a selection that spans the pages', async () => { + const {result, rerender} = renderSelection(); + await act(async () => { + result.current.toggle(firstPageRowA); + result.current.toggle(laterPageRowB); + await waitForBatchedUpdatesWithAct(); + }); + + renderedRows = firstPage; + renderedHash = FOOTER_TOTAL_HASH; + rerender({}); + await waitForBatchedUpdatesWithAct(); + + // The loaded row is rebuilt from what is on screen, the unloaded one is carried over + expect(Object.keys(result.current.selectedTransactions).sort()).toEqual(['1', '4']); + }); + + it('keeps them across the passes that follow the switch, as the data settles', async () => { + const {result, rerender} = renderSelection(); + await act(async () => { + result.current.toggle(laterPageRowB); + await waitForBatchedUpdatesWithAct(); + }); + + renderedRows = firstPage; + renderedHash = FOOTER_TOTAL_HASH; + rerender({}); + await waitForBatchedUpdatesWithAct(); + + // A later Onyx push re-runs the reconcile against the same first page + renderedRows = [...firstPage]; + rerender({}); + await waitForBatchedUpdatesWithAct(); + + expect(Object.keys(result.current.selectedTransactions)).toEqual(['4']); + }); + + 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 a hash that is not a footer sibling + renderedRows = firstPage; + renderedHash = OTHER_SEARCH_HASH; + renderedFooterlessHash = OTHER_FOOTERLESS_HASH; + rerender({}); + await waitForBatchedUpdatesWithAct(); + + // Then the selection is not carried into it + expect(result.current.selectedTransactions).toEqual({}); + }); +}); diff --git a/tests/unit/Search/LazyGroupSelectionTest.tsx b/tests/unit/Search/LazyGroupSelectionTest.tsx index 5d8fc441e036..c544124a9261 100644 --- a/tests/unit/Search/LazyGroupSelectionTest.tsx +++ b/tests/unit/Search/LazyGroupSelectionTest.tsx @@ -80,6 +80,7 @@ function SettledGroupWrapper({children}: {children: React.ReactNode}) { totalSelectableItemsCount={loadedChildren.length} searchResults={settledGroupedResults} searchHash={SEARCH_HASH} + searchHashWithoutFooterSelections={SEARCH_HASH} transactions={undefined} isMobileSelectionModeEnabled={false} type={CONST.SEARCH.DATA_TYPES.EXPENSE} @@ -113,6 +114,7 @@ function PagingWrapper({children}: {children: React.ReactNode}) { totalSelectableItemsCount={5} searchResults={undefined} searchHash={SEARCH_HASH} + searchHashWithoutFooterSelections={SEARCH_HASH} transactions={undefined} isMobileSelectionModeEnabled={false} type={CONST.SEARCH.DATA_TYPES.EXPENSE} @@ -135,6 +137,7 @@ function TwoGroupWrapper({children}: {children: React.ReactNode}) { totalSelectableItemsCount={4} searchResults={undefined} searchHash={SEARCH_HASH} + searchHashWithoutFooterSelections={SEARCH_HASH} transactions={undefined} isMobileSelectionModeEnabled={false} type={CONST.SEARCH.DATA_TYPES.EXPENSE} @@ -255,6 +258,7 @@ function SearchChangeWrapper({children}: {children: React.ReactNode}) { totalSelectableItemsCount={2} searchResults={groupedSearchResults} searchHash={groupedSearchHash} + searchHashWithoutFooterSelections={groupedSearchHash} transactions={undefined} isMobileSelectionModeEnabled={false} type={CONST.SEARCH.DATA_TYPES.EXPENSE} @@ -277,6 +281,7 @@ function Wrapper({children}: {children: React.ReactNode}) { totalSelectableItemsCount={2} searchResults={undefined} searchHash={groupedSearchHash} + searchHashWithoutFooterSelections={groupedSearchHash} transactions={undefined} isMobileSelectionModeEnabled={false} type={CONST.SEARCH.DATA_TYPES.EXPENSE} @@ -299,6 +304,7 @@ function FlatWrapper({children}: {children: React.ReactNode}) { totalSelectableItemsCount={flatFilteredData.length} searchResults={flatSearchResults} searchHash={SEARCH_HASH} + searchHashWithoutFooterSelections={SEARCH_HASH} transactions={undefined} isMobileSelectionModeEnabled={false} type={CONST.SEARCH.DATA_TYPES.EXPENSE} @@ -322,6 +328,7 @@ function ReportsWrapper({children}: {children: React.ReactNode}) { totalSelectableItemsCount={totalSelectableItemsCount} searchResults={reportSearchResults} searchHash={SEARCH_HASH} + searchHashWithoutFooterSelections={SEARCH_HASH} transactions={undefined} isMobileSelectionModeEnabled={false} type={CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} @@ -358,6 +365,7 @@ function LoadedReportWrapper({children}: {children: React.ReactNode}) { totalSelectableItemsCount={3} searchResults={undefined} searchHash={SEARCH_HASH} + searchHashWithoutFooterSelections={SEARCH_HASH} transactions={undefined} isMobileSelectionModeEnabled={false} type={CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} @@ -387,6 +395,7 @@ function DeletedRowReportWrapper({children}: {children: React.ReactNode}) { totalSelectableItemsCount={2} searchResults={undefined} searchHash={SEARCH_HASH} + searchHashWithoutFooterSelections={SEARCH_HASH} transactions={undefined} isMobileSelectionModeEnabled={false} type={CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} @@ -409,6 +418,7 @@ function ExpenseReportWrapper({children}: {children: React.ReactNode}) { totalSelectableItemsCount={3} searchResults={undefined} searchHash={SEARCH_HASH} + searchHashWithoutFooterSelections={SEARCH_HASH} transactions={undefined} isMobileSelectionModeEnabled={false} type={CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} From 0eb3075b7dd3df64d093a12779d7a9f429a870b7 Mon Sep 17 00:00:00 2001 From: truph01 Date: Fri, 2 Oct 2026 17:16:28 +0700 Subject: [PATCH 57/86] fix: gate the Spend footer UI behind the beta, not just its data --- src/components/Search/SearchPageFooter.tsx | 88 ++++++++++++++++++- .../Search/SearchPageFooterSkeleton.tsx | 45 ++++++++-- tests/unit/Search/SearchPageFooterTest.tsx | 80 ++++++++++++++++- 3 files changed, 201 insertions(+), 12 deletions(-) diff --git a/src/components/Search/SearchPageFooter.tsx b/src/components/Search/SearchPageFooter.tsx index cbd3b4f45589..ab2dcced31ad 100644 --- a/src/components/Search/SearchPageFooter.tsx +++ b/src/components/Search/SearchPageFooter.tsx @@ -6,6 +6,7 @@ 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'; @@ -18,12 +19,13 @@ import CONST from '@src/CONST'; import type {LayoutChangeEvent} from 'react-native'; import React, {useMemo, useState} from 'react'; -import {View} from 'react-native'; +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'; @@ -88,6 +90,8 @@ function SearchPageFooter({ 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. @@ -120,6 +124,88 @@ function SearchPageFooter({ onCurrencyChange(nextCurrency); }; + const renderCurrencyPopup: FilterPopupButtonProps['PopoverComponent'] = ({closeOverlay, isExpanded}) => ( + + ); + + const totalButton = (props: ButtonComponentProps) => ( + + ); + + if (!isFooterSelectorsEnabled) { + return ( + + + + {`${translate('common.expenses')}:`} + {count} + + {typeof total === 'number' && ( + + {`${translate('common.totalSpend')}:`} + + + )} + + {isTotalLoading && ( + + + + )} + + ); + } + const renderFooterPopup: FilterPopupButtonProps['PopoverComponent'] = ({closeOverlay, isExpanded}) => (