From c29d28629f437a9c8e5113748e4045dcbfd91619 Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Thu, 22 Jan 2026 22:33:53 +0700 Subject: [PATCH 1/7] Add sticky filters to the reports page --- src/pages/Search/SearchTypeMenu.tsx | 62 ++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx index eeba8905da81f..8bf6a1bda626d 100644 --- a/src/pages/Search/SearchTypeMenu.tsx +++ b/src/pages/Search/SearchTypeMenu.tsx @@ -26,14 +26,22 @@ import {setSearchContext} from '@libs/actions/Search'; import {filterPersonalCards, mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; -import {buildSearchQueryJSON, buildUserReadableQueryString, shouldSkipSuggestedSearchNavigation as shouldSkipSuggestedSearchNavigationForQuery} from '@libs/SearchQueryUtils'; +import { + buildQueryStringFromFilterFormValues, + buildSearchQueryJSON, + buildSearchQueryString, + buildUserReadableQueryString, + shouldSkipSuggestedSearchNavigation as shouldSkipSuggestedSearchNavigationForQuery, +} from '@libs/SearchQueryUtils'; import type {SavedSearchMenuItem} from '@libs/SearchUIUtils'; import {createBaseSavedSearchMenuItem, getOverflowMenu as getOverflowMenuUtil} from '@libs/SearchUIUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {SearchAdvancedFiltersForm} from '@src/types/form'; import type {SaveSearchItem} from '@src/types/onyx/SaveSearch'; +import {getEmptyObject} from '@src/types/utils/EmptyObject'; import SavedSearchItemThreeDotMenu from './SavedSearchItemThreeDotMenu'; import SuggestedSearchSkeleton from './SuggestedSearchSkeleton'; @@ -81,6 +89,7 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { const taxRates = getAllTaxRates(allPolicies); const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector, canBeMissing: false}); const {clearSelectedTransactions} = useSearchContext(); + const [searchAdvancedFiltersForm = getEmptyObject>()] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {canBeMissing: true}); const flattenedMenuItems = useMemo(() => typeMenuSections.flatMap((section) => section.menuItems), [typeMenuSections]); @@ -212,14 +221,32 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { [expensifyIcons.Bookmark, styles.sectionMenuItem], ); - const activeItemIndex = useMemo(() => { + const [activeItemIndex, isExploreSection] = useMemo(() => { // If we have a suggested search, then none of the menu items are active if (isSavedSearchActive) { - return -1; + return [-1, false]; } - return flattenedMenuItems.findIndex((item) => item.similarSearchHash === similarSearchHash); - }, [similarSearchHash, isSavedSearchActive, flattenedMenuItems]); + let isMatchedIndex = flattenedMenuItems.findIndex((item) => item.similarSearchHash === similarSearchHash); + if (isMatchedIndex === -1) { + isMatchedIndex = flattenedMenuItems.findIndex((item) => { + if (queryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE) { + return item.key === CONST.SEARCH.SEARCH_KEYS.EXPENSES; + } + if (queryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { + return item.key === CONST.SEARCH.SEARCH_KEYS.REPORTS; + } + if (queryJSON?.type === CONST.SEARCH.DATA_TYPES.CHAT) { + return item.key === CONST.SEARCH.SEARCH_KEYS.CHATS; + } + return false; + }); + } + const matchedItemKey = isMatchedIndex !== -1 ? flattenedMenuItems.at(isMatchedIndex)?.key : undefined; + const isExploreSection1 = + !!matchedItemKey && ([CONST.SEARCH.SEARCH_KEYS.EXPENSES, CONST.SEARCH.SEARCH_KEYS.REPORTS, CONST.SEARCH.SEARCH_KEYS.CHATS] as string[]).includes(matchedItemKey); + return [isMatchedIndex, isExploreSection1]; + }, [similarSearchHash, isSavedSearchActive, flattenedMenuItems, queryJSON?.type]); return ( <> @@ -258,7 +285,30 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { const onPress = singleExecution(() => { clearSelectedTransactions(); setSearchContext(false); - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: item.searchQuery})); + let queryString = item.searchQuery; + + if (isExploreSection && section.translationPath === 'common.explore') { + const updatedFilterFormValues: Partial = { + ...searchAdvancedFiltersForm, + ...{type: item.type}, + }; + + // If the type has changed, reset the columns + if (updatedFilterFormValues.type !== searchAdvancedFiltersForm.type) { + updatedFilterFormValues.columns = []; + } + + // Preserve the current sortBy and sortOrder from queryJSON when updating filters + const updatedQueryString = buildQueryStringFromFilterFormValues(updatedFilterFormValues, { + sortBy: queryJSON?.sortBy, + sortOrder: queryJSON?.sortOrder, + }); + + // We need to normalize the updatedQueryString using buildSearchQueryString. + const updatedQueryJSON = buildSearchQueryJSON(updatedQueryString); + queryString = buildSearchQueryString(updatedQueryJSON); + } + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryString})); }); return ( From 5a73135ed4fd41813e5793dae0ff1c8642d9efd2 Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Thu, 22 Jan 2026 22:59:03 +0700 Subject: [PATCH 2/7] Add sticky filters to the reports page --- src/hooks/useSearchTypeMenu.tsx | 77 ++++++++++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 7 deletions(-) diff --git a/src/hooks/useSearchTypeMenu.tsx b/src/hooks/useSearchTypeMenu.tsx index d4bcf3326b203..221e86d7c4440 100644 --- a/src/hooks/useSearchTypeMenu.tsx +++ b/src/hooks/useSearchTypeMenu.tsx @@ -10,13 +10,20 @@ import {setSearchContext} from '@libs/actions/Search'; import {filterPersonalCards, mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; -import {buildSearchQueryJSON, buildUserReadableQueryString, shouldSkipSuggestedSearchNavigation as shouldSkipSuggestedSearchNavigationForQuery} from '@libs/SearchQueryUtils'; +import { + buildQueryStringFromFilterFormValues, + buildSearchQueryJSON, + buildSearchQueryString, + buildUserReadableQueryString, + shouldSkipSuggestedSearchNavigation as shouldSkipSuggestedSearchNavigationForQuery, +} from '@libs/SearchQueryUtils'; import type {SavedSearchMenuItem} from '@libs/SearchUIUtils'; import {createBaseSavedSearchMenuItem, getOverflowMenu as getOverflowMenuUtil} from '@libs/SearchUIUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {SearchAdvancedFiltersForm} from '@src/types/form'; import type {Report} from '@src/types/onyx'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; import useDeleteSavedSearch from './useDeleteSavedSearch'; @@ -68,6 +75,7 @@ export default function useSearchTypeMenu(queryJSON: SearchQueryJSON) { const allCards = useMemo(() => mergeCardListWithWorkspaceFeeds(workspaceCardFeeds ?? CONST.EMPTY_OBJECT, userCardList), [userCardList, workspaceCardFeeds]); const [allFeeds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER, {canBeMissing: true}); const flattenedMenuItems = useMemo(() => typeMenuSections.flatMap((section) => section.menuItems), [typeMenuSections]); + const [searchAdvancedFiltersForm = getEmptyObject>()] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {canBeMissing: true}); useSuggestedSearchDefaultNavigation({ shouldShowSkeleton: shouldShowSuggestedSearchSkeleton, @@ -150,14 +158,32 @@ export default function useSearchTypeMenu(queryJSON: SearchQueryJSON) { }; }, [savedSearches, hash, getOverflowMenu, expensifyIcons.Bookmark, personalDetails, reports, taxRates, allCards, allFeeds, allPolicies, currentUserAccountID]); - const activeItemIndex = useMemo(() => { + const [activeItemIndex, isExploreSection] = useMemo(() => { // If we have a suggested search, then none of the menu items are active if (isSavedSearchActive) { - return -1; + return [-1, false]; } - return flattenedMenuItems.findIndex((item) => item.similarSearchHash === similarSearchHash); - }, [similarSearchHash, isSavedSearchActive, flattenedMenuItems]); + let isMatchedIndex = flattenedMenuItems.findIndex((item) => item.similarSearchHash === similarSearchHash); + if (isMatchedIndex === -1) { + isMatchedIndex = flattenedMenuItems.findIndex((item) => { + if (queryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE) { + return item.key === CONST.SEARCH.SEARCH_KEYS.EXPENSES; + } + if (queryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { + return item.key === CONST.SEARCH.SEARCH_KEYS.REPORTS; + } + if (queryJSON?.type === CONST.SEARCH.DATA_TYPES.CHAT) { + return item.key === CONST.SEARCH.SEARCH_KEYS.CHATS; + } + return false; + }); + } + const matchedItemKey = isMatchedIndex !== -1 ? flattenedMenuItems.at(isMatchedIndex)?.key : undefined; + const isExploreSection1 = + !!matchedItemKey && ([CONST.SEARCH.SEARCH_KEYS.EXPENSES, CONST.SEARCH.SEARCH_KEYS.REPORTS, CONST.SEARCH.SEARCH_KEYS.CHATS] as string[]).includes(matchedItemKey); + return [isMatchedIndex, isExploreSection1]; + }, [similarSearchHash, isSavedSearchActive, flattenedMenuItems, queryJSON?.type]); const popoverMenuItems = useMemo(() => { return typeMenuSections @@ -194,7 +220,30 @@ export default function useSearchTypeMenu(queryJSON: SearchQueryJSON) { shouldCallAfterModalHide: true, onSelected: singleExecution(() => { setSearchContext(false); - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: item.searchQuery})); + let queryString = item.searchQuery; + + if (isExploreSection && section.translationPath === 'common.explore') { + const updatedFilterFormValues: Partial = { + ...searchAdvancedFiltersForm, + ...{type: item.type}, + }; + + // If the type has changed, reset the columns + if (updatedFilterFormValues.type !== searchAdvancedFiltersForm.type) { + updatedFilterFormValues.columns = []; + } + + // Preserve the current sortBy and sortOrder from queryJSON when updating filters + const updatedQueryString = buildQueryStringFromFilterFormValues(updatedFilterFormValues, { + sortBy: queryJSON?.sortBy, + sortOrder: queryJSON?.sortOrder, + }); + + // We need to normalize the updatedQueryString using buildSearchQueryString. + const updatedQueryJSON = buildSearchQueryJSON(updatedQueryString); + queryString = buildSearchQueryString(updatedQueryJSON); + } + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryString})); }), }); } @@ -203,7 +252,21 @@ export default function useSearchTypeMenu(queryJSON: SearchQueryJSON) { return sectionItems; }) .flat(); - }, [typeMenuSections, translate, styles.textSupporting, savedSearchesMenuItems, activeItemIndex, theme.iconSuccessFill, theme.border, expensifyIcons, singleExecution]); + }, [ + typeMenuSections, + translate, + styles.textSupporting, + savedSearchesMenuItems, + activeItemIndex, + expensifyIcons, + theme.iconSuccessFill, + theme.border, + singleExecution, + isExploreSection, + searchAdvancedFiltersForm, + queryJSON?.sortBy, + queryJSON?.sortOrder, + ]); const openMenu = useCallback(() => { setIsPopoverVisible(true); From 6dc34d8bef6f3d07a093e89f5cdd803ed32e7435 Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Fri, 23 Jan 2026 00:08:01 +0700 Subject: [PATCH 3/7] Add sticky filters to the reports page --- src/hooks/useSearchTypeMenu.tsx | 67 +++++----------------------- src/libs/SearchUIUtils.ts | 68 +++++++++++++++++++++++++++++ src/pages/Search/SearchTypeMenu.tsx | 62 ++++---------------------- 3 files changed, 86 insertions(+), 111 deletions(-) diff --git a/src/hooks/useSearchTypeMenu.tsx b/src/hooks/useSearchTypeMenu.tsx index 221e86d7c4440..cc1542c51f512 100644 --- a/src/hooks/useSearchTypeMenu.tsx +++ b/src/hooks/useSearchTypeMenu.tsx @@ -10,15 +10,9 @@ import {setSearchContext} from '@libs/actions/Search'; import {filterPersonalCards, mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; -import { - buildQueryStringFromFilterFormValues, - buildSearchQueryJSON, - buildSearchQueryString, - buildUserReadableQueryString, - shouldSkipSuggestedSearchNavigation as shouldSkipSuggestedSearchNavigationForQuery, -} from '@libs/SearchQueryUtils'; +import {buildSearchQueryJSON, buildUserReadableQueryString, shouldSkipSuggestedSearchNavigation as shouldSkipSuggestedSearchNavigationForQuery} from '@libs/SearchQueryUtils'; import type {SavedSearchMenuItem} from '@libs/SearchUIUtils'; -import {createBaseSavedSearchMenuItem, getOverflowMenu as getOverflowMenuUtil} from '@libs/SearchUIUtils'; +import {createBaseSavedSearchMenuItem, getActiveSearchItemIndex, getOverflowMenu as getOverflowMenuUtil, updateQueryStringOnSearchTypeChange} from '@libs/SearchUIUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -158,32 +152,10 @@ export default function useSearchTypeMenu(queryJSON: SearchQueryJSON) { }; }, [savedSearches, hash, getOverflowMenu, expensifyIcons.Bookmark, personalDetails, reports, taxRates, allCards, allFeeds, allPolicies, currentUserAccountID]); - const [activeItemIndex, isExploreSection] = useMemo(() => { - // If we have a suggested search, then none of the menu items are active - if (isSavedSearchActive) { - return [-1, false]; - } - - let isMatchedIndex = flattenedMenuItems.findIndex((item) => item.similarSearchHash === similarSearchHash); - if (isMatchedIndex === -1) { - isMatchedIndex = flattenedMenuItems.findIndex((item) => { - if (queryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE) { - return item.key === CONST.SEARCH.SEARCH_KEYS.EXPENSES; - } - if (queryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { - return item.key === CONST.SEARCH.SEARCH_KEYS.REPORTS; - } - if (queryJSON?.type === CONST.SEARCH.DATA_TYPES.CHAT) { - return item.key === CONST.SEARCH.SEARCH_KEYS.CHATS; - } - return false; - }); - } - const matchedItemKey = isMatchedIndex !== -1 ? flattenedMenuItems.at(isMatchedIndex)?.key : undefined; - const isExploreSection1 = - !!matchedItemKey && ([CONST.SEARCH.SEARCH_KEYS.EXPENSES, CONST.SEARCH.SEARCH_KEYS.REPORTS, CONST.SEARCH.SEARCH_KEYS.CHATS] as string[]).includes(matchedItemKey); - return [isMatchedIndex, isExploreSection1]; - }, [similarSearchHash, isSavedSearchActive, flattenedMenuItems, queryJSON?.type]); + const [activeItemIndex, isExploreSectionActive] = useMemo( + () => getActiveSearchItemIndex(flattenedMenuItems, similarSearchHash, isSavedSearchActive, queryJSON?.type), + [similarSearchHash, isSavedSearchActive, flattenedMenuItems, queryJSON?.type], + ); const popoverMenuItems = useMemo(() => { return typeMenuSections @@ -222,26 +194,8 @@ export default function useSearchTypeMenu(queryJSON: SearchQueryJSON) { setSearchContext(false); let queryString = item.searchQuery; - if (isExploreSection && section.translationPath === 'common.explore') { - const updatedFilterFormValues: Partial = { - ...searchAdvancedFiltersForm, - ...{type: item.type}, - }; - - // If the type has changed, reset the columns - if (updatedFilterFormValues.type !== searchAdvancedFiltersForm.type) { - updatedFilterFormValues.columns = []; - } - - // Preserve the current sortBy and sortOrder from queryJSON when updating filters - const updatedQueryString = buildQueryStringFromFilterFormValues(updatedFilterFormValues, { - sortBy: queryJSON?.sortBy, - sortOrder: queryJSON?.sortOrder, - }); - - // We need to normalize the updatedQueryString using buildSearchQueryString. - const updatedQueryJSON = buildSearchQueryJSON(updatedQueryString); - queryString = buildSearchQueryString(updatedQueryJSON); + if (isExploreSectionActive && section.translationPath === 'common.explore') { + queryString = updateQueryStringOnSearchTypeChange(item.type, searchAdvancedFiltersForm, queryJSON); } Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryString})); }), @@ -262,10 +216,9 @@ export default function useSearchTypeMenu(queryJSON: SearchQueryJSON) { theme.iconSuccessFill, theme.border, singleExecution, - isExploreSection, + isExploreSectionActive, searchAdvancedFiltersForm, - queryJSON?.sortBy, - queryJSON?.sortOrder, + queryJSON, ]); const openMenu = useCallback(() => { diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 1cc5c295ac061..fec9a8979f835 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -44,6 +44,7 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {SearchAdvancedFiltersForm} from '@src/types/form'; import type * as OnyxTypes from '@src/types/onyx'; import type {ConnectionName} from '@src/types/onyx/Policy'; import type {SaveSearchItem} from '@src/types/onyx/SaveSearch'; @@ -3491,6 +3492,71 @@ function getTableMinWidth(columns: SearchColumnType[]) { return minWidth; } +/** + * Returns the index of the active item in flattenedMenuItems by comparing similarSearchHash. + * + * Also returns a value indicating whether the item in the Explore section is active + */ +function getActiveSearchItemIndex( + flattenedMenuItems: SearchTypeMenuItem[], + similarSearchHash: number | undefined, + isSavedSearchActive: boolean, + queryType: string | undefined, +): [number, boolean] { + // If we have a suggested search, then none of the menu items are active + if (isSavedSearchActive) { + return [-1, false]; + } + + let activeItemIndex = flattenedMenuItems.findIndex((item) => item.similarSearchHash === similarSearchHash); + if (activeItemIndex === -1) { + activeItemIndex = flattenedMenuItems.findIndex((item) => { + if (queryType === CONST.SEARCH.DATA_TYPES.EXPENSE) { + return item.key === CONST.SEARCH.SEARCH_KEYS.EXPENSES; + } + if (queryType === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { + return item.key === CONST.SEARCH.SEARCH_KEYS.REPORTS; + } + if (queryType === CONST.SEARCH.DATA_TYPES.CHAT) { + return item.key === CONST.SEARCH.SEARCH_KEYS.CHATS; + } + return false; + }); + } + const activeItemKey = activeItemIndex !== -1 ? flattenedMenuItems.at(activeItemIndex)?.key : undefined; + const isExploreSectionActive = + !!activeItemKey && ([CONST.SEARCH.SEARCH_KEYS.EXPENSES, CONST.SEARCH.SEARCH_KEYS.REPORTS, CONST.SEARCH.SEARCH_KEYS.CHATS] as string[]).includes(activeItemKey); + return [activeItemIndex, isExploreSectionActive]; +} + +/** + * Rebuild the query string based on searchAdvancedFiltersForm and the new value of the search type. + * The filter values that are valid for the new search type will be preserved. + */ +function updateQueryStringOnSearchTypeChange(type: SearchDataTypes, searchAdvancedFiltersForm: Partial, queryJSON: SearchQueryJSON | undefined): string { + const updatedFilterFormValues: Partial = { + ...searchAdvancedFiltersForm, + type, + }; + + // If the type has changed, reset the columns + if (updatedFilterFormValues.type !== searchAdvancedFiltersForm.type) { + updatedFilterFormValues.columns = []; + } + + // Preserve the current sortBy and sortOrder from queryJSON when updating filters + const updatedQueryString = buildQueryStringFromFilterFormValues(updatedFilterFormValues, { + sortBy: queryJSON?.sortBy, + sortOrder: queryJSON?.sortOrder, + }); + + // We need to normalize the updatedQueryString using buildSearchQueryString. + const updatedQueryJSON = buildSearchQueryJSON(updatedQueryString); + const queryString = buildSearchQueryString(updatedQueryJSON); + + return queryString; +} + export { getSuggestedSearches, getDefaultActionableSearchMenuItem, @@ -3540,5 +3606,7 @@ export { getTableMinWidth, getCustomColumns, getCustomColumnDefault, + getActiveSearchItemIndex, + updateQueryStringOnSearchTypeChange, }; export type {SavedSearchMenuItem, SearchTypeMenuSection, SearchTypeMenuItem, SearchDateModifier, SearchDateModifierLower, SearchKey, ArchivedReportsIDSet}; diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx index 8bf6a1bda626d..6629531763334 100644 --- a/src/pages/Search/SearchTypeMenu.tsx +++ b/src/pages/Search/SearchTypeMenu.tsx @@ -26,15 +26,9 @@ import {setSearchContext} from '@libs/actions/Search'; import {filterPersonalCards, mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; -import { - buildQueryStringFromFilterFormValues, - buildSearchQueryJSON, - buildSearchQueryString, - buildUserReadableQueryString, - shouldSkipSuggestedSearchNavigation as shouldSkipSuggestedSearchNavigationForQuery, -} from '@libs/SearchQueryUtils'; +import {buildSearchQueryJSON, buildUserReadableQueryString, shouldSkipSuggestedSearchNavigation as shouldSkipSuggestedSearchNavigationForQuery} from '@libs/SearchQueryUtils'; import type {SavedSearchMenuItem} from '@libs/SearchUIUtils'; -import {createBaseSavedSearchMenuItem, getOverflowMenu as getOverflowMenuUtil} from '@libs/SearchUIUtils'; +import {createBaseSavedSearchMenuItem, getActiveSearchItemIndex, getOverflowMenu as getOverflowMenuUtil, updateQueryStringOnSearchTypeChange} from '@libs/SearchUIUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -221,32 +215,10 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { [expensifyIcons.Bookmark, styles.sectionMenuItem], ); - const [activeItemIndex, isExploreSection] = useMemo(() => { - // If we have a suggested search, then none of the menu items are active - if (isSavedSearchActive) { - return [-1, false]; - } - - let isMatchedIndex = flattenedMenuItems.findIndex((item) => item.similarSearchHash === similarSearchHash); - if (isMatchedIndex === -1) { - isMatchedIndex = flattenedMenuItems.findIndex((item) => { - if (queryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE) { - return item.key === CONST.SEARCH.SEARCH_KEYS.EXPENSES; - } - if (queryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { - return item.key === CONST.SEARCH.SEARCH_KEYS.REPORTS; - } - if (queryJSON?.type === CONST.SEARCH.DATA_TYPES.CHAT) { - return item.key === CONST.SEARCH.SEARCH_KEYS.CHATS; - } - return false; - }); - } - const matchedItemKey = isMatchedIndex !== -1 ? flattenedMenuItems.at(isMatchedIndex)?.key : undefined; - const isExploreSection1 = - !!matchedItemKey && ([CONST.SEARCH.SEARCH_KEYS.EXPENSES, CONST.SEARCH.SEARCH_KEYS.REPORTS, CONST.SEARCH.SEARCH_KEYS.CHATS] as string[]).includes(matchedItemKey); - return [isMatchedIndex, isExploreSection1]; - }, [similarSearchHash, isSavedSearchActive, flattenedMenuItems, queryJSON?.type]); + const [activeItemIndex, isExploreSectionActive] = useMemo( + () => getActiveSearchItemIndex(flattenedMenuItems, similarSearchHash, isSavedSearchActive, queryJSON?.type), + [similarSearchHash, isSavedSearchActive, flattenedMenuItems, queryJSON?.type], + ); return ( <> @@ -287,26 +259,8 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { setSearchContext(false); let queryString = item.searchQuery; - if (isExploreSection && section.translationPath === 'common.explore') { - const updatedFilterFormValues: Partial = { - ...searchAdvancedFiltersForm, - ...{type: item.type}, - }; - - // If the type has changed, reset the columns - if (updatedFilterFormValues.type !== searchAdvancedFiltersForm.type) { - updatedFilterFormValues.columns = []; - } - - // Preserve the current sortBy and sortOrder from queryJSON when updating filters - const updatedQueryString = buildQueryStringFromFilterFormValues(updatedFilterFormValues, { - sortBy: queryJSON?.sortBy, - sortOrder: queryJSON?.sortOrder, - }); - - // We need to normalize the updatedQueryString using buildSearchQueryString. - const updatedQueryJSON = buildSearchQueryJSON(updatedQueryString); - queryString = buildSearchQueryString(updatedQueryJSON); + if (isExploreSectionActive && section.translationPath === 'common.explore') { + queryString = updateQueryStringOnSearchTypeChange(item.type, searchAdvancedFiltersForm, queryJSON); } Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryString})); }); From 6a5529e1646d0f0dd962ab8a38f936ea08bd81bb Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Tue, 27 Jan 2026 20:19:21 +0700 Subject: [PATCH 4/7] Add sticky filters to the reports page --- src/libs/SearchUIUtils.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index fec9a8979f835..c65b505f8ba33 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -3541,6 +3541,19 @@ function updateQueryStringOnSearchTypeChange(type: SearchDataTypes, searchAdvanc // If the type has changed, reset the columns if (updatedFilterFormValues.type !== searchAdvancedFiltersForm.type) { + const newStatus = []; + const currentStatus = typeof updatedFilterFormValues.status === 'string' ? updatedFilterFormValues.status.split(',') : (updatedFilterFormValues.status ?? []); + + for (const status of currentStatus) { + if ( + (type === CONST.SEARCH.DATA_TYPES.EXPENSE && Object.values(CONST.SEARCH.STATUS.EXPENSE).includes(status)) || + (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && Object.values(CONST.SEARCH.STATUS.EXPENSE_REPORT).includes(status)) + ) { + newStatus.push(status); + } + } + + updatedFilterFormValues.status = newStatus.length === 0 ? CONST.SEARCH.STATUS.EXPENSE.ALL : newStatus; updatedFilterFormValues.columns = []; } From bff245a39609e30c5c0ab47e85cc77bcd27d5de9 Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Wed, 28 Jan 2026 18:12:59 +0700 Subject: [PATCH 5/7] Add sticky filters to the reports page --- src/hooks/useSearchTypeMenu.tsx | 26 +++-------- src/hooks/useSearchTypeMenuSections.ts | 2 +- src/hooks/useStickySearchFilters.ts | 60 ++++++++++++++++++++++++++ src/libs/SearchUIUtils.ts | 22 +++++----- src/pages/Search/SearchTypeMenu.tsx | 10 ++--- 5 files changed, 84 insertions(+), 36 deletions(-) create mode 100644 src/hooks/useStickySearchFilters.ts diff --git a/src/hooks/useSearchTypeMenu.tsx b/src/hooks/useSearchTypeMenu.tsx index e97219a280e68..d47d0dd5a8024 100644 --- a/src/hooks/useSearchTypeMenu.tsx +++ b/src/hooks/useSearchTypeMenu.tsx @@ -16,15 +16,15 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {SearchAdvancedFiltersForm} from '@src/types/form'; import type {Report} from '@src/types/onyx'; -import {getEmptyObject} from '@src/types/utils/EmptyObject'; +import {getEmptyObject, isEmptyObject} from '@src/types/utils/EmptyObject'; import useDeleteSavedSearch from './useDeleteSavedSearch'; import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; import useLocalize from './useLocalize'; import useOnyx from './useOnyx'; import useSearchTypeMenuSections from './useSearchTypeMenuSections'; import useSingleExecution from './useSingleExecution'; +import useStickySearchFilters from './useStickySearchFilters'; import useSuggestedSearchDefaultNavigation from './useSuggestedSearchDefaultNavigation'; import useTheme from './useTheme'; import useThemeStyles from './useThemeStyles'; @@ -66,7 +66,6 @@ export default function useSearchTypeMenu(queryJSON: SearchQueryJSON) { const [allFeeds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER, {canBeMissing: true}); const flattenedMenuItems = useMemo(() => typeMenuSections.flatMap((section) => section.menuItems), [typeMenuSections]); - const [searchAdvancedFiltersForm = getEmptyObject>()] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {canBeMissing: true}); useSuggestedSearchDefaultNavigation({ shouldShowSkeleton: shouldShowSuggestedSearchSkeleton, @@ -154,6 +153,8 @@ export default function useSearchTypeMenu(queryJSON: SearchQueryJSON) { [similarSearchHash, isSavedSearchActive, flattenedMenuItems, queryJSON?.type], ); + const allSearchAdvancedFilters = useStickySearchFilters(isExploreSectionActive && !shouldShowSuggestedSearchSkeleton); + const popoverMenuItems = useMemo(() => { return typeMenuSections .map((section, sectionIndex) => { @@ -189,8 +190,8 @@ export default function useSearchTypeMenu(queryJSON: SearchQueryJSON) { setSearchContext(false); let queryString = item.searchQuery; - if (isExploreSectionActive && section.translationPath === 'common.explore') { - queryString = updateQueryStringOnSearchTypeChange(item.type, searchAdvancedFiltersForm, queryJSON); + if (section.translationPath === 'common.explore' && !isEmptyObject(allSearchAdvancedFilters)) { + queryString = updateQueryStringOnSearchTypeChange(item.type, allSearchAdvancedFilters, queryJSON); } Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryString})); }), @@ -201,20 +202,7 @@ export default function useSearchTypeMenu(queryJSON: SearchQueryJSON) { return sectionItems; }) .flat(); - }, [ - typeMenuSections, - translate, - styles.textSupporting, - savedSearchesMenuItems, - activeItemIndex, - expensifyIcons, - - theme.border, - singleExecution, - isExploreSectionActive, - searchAdvancedFiltersForm, - queryJSON, - ]); + }, [typeMenuSections, translate, styles.textSupporting, savedSearchesMenuItems, activeItemIndex, expensifyIcons, theme.border, singleExecution, allSearchAdvancedFilters, queryJSON]); const openMenu = useCallback(() => { setIsPopoverVisible(true); diff --git a/src/hooks/useSearchTypeMenuSections.ts b/src/hooks/useSearchTypeMenuSections.ts index 005e64ee03d9d..de8a7417853d9 100644 --- a/src/hooks/useSearchTypeMenuSections.ts +++ b/src/hooks/useSearchTypeMenuSections.ts @@ -91,7 +91,7 @@ const useSearchTypeMenuSections = () => { const isSuggestedSearchDataReady = useMemo(() => { const policiesList = Object.values(allPolicies ?? {}).filter((policy): policy is NonNullable => policy !== null && policy !== undefined); - return policiesList.some((policy) => policy.employeeList !== undefined && policy.exporter !== undefined); + return policiesList.some((policy) => policy.id !== undefined && policy.employeeList !== undefined && policy.exporter !== undefined); }, [allPolicies]); const typeMenuSections = useMemo( diff --git a/src/hooks/useStickySearchFilters.ts b/src/hooks/useStickySearchFilters.ts new file mode 100644 index 0000000000000..66708189d6e37 --- /dev/null +++ b/src/hooks/useStickySearchFilters.ts @@ -0,0 +1,60 @@ +import {useEffect} from 'react'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {SearchAdvancedFiltersForm} from '@src/types/form'; +import {getEmptyObject, isEmptyObject} from '@src/types/utils/EmptyObject'; +import useOnyx from './useOnyx'; + +const allSearchAdvancedFilters: {current: Partial} = {current: {}}; +const prevSearchAdvancedFiltersFormsByType: {current: Record | undefined>} = {current: {}}; +/** + * This hook helps retain all filter values and will only update the filters that have changed + */ +export default function useStickySearchFilters(shouldUpdate?: boolean) { + const [searchAdvancedFiltersForm = getEmptyObject>()] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {canBeMissing: true}); + + useEffect(() => { + if (!shouldUpdate || !searchAdvancedFiltersForm.type) { + return; + } + + const prevSearchAdvancedFiltersForm = prevSearchAdvancedFiltersFormsByType.current[searchAdvancedFiltersForm.type]; + const allKeys = new Set([...Object.keys(searchAdvancedFiltersForm), ...Object.keys(prevSearchAdvancedFiltersForm ?? {})]) as Set; + const changedKeys: Array = []; + for (const key of allKeys) { + const currentValue = searchAdvancedFiltersForm[key]; + const previousValue = prevSearchAdvancedFiltersForm?.[key]; + if (Array.isArray(currentValue) && Array.isArray(previousValue)) { + if (currentValue.sort().join(',') === previousValue.sort().join(',')) { + continue; + } + } else if (Object.is(currentValue, previousValue)) { + continue; + } + + changedKeys.push(key); + } + + for (const key of changedKeys) { + if (!prevSearchAdvancedFiltersForm && allSearchAdvancedFilters.current[key]) { + continue; + } + (allSearchAdvancedFilters.current[key] as unknown) = searchAdvancedFiltersForm[key] ?? undefined; + } + allSearchAdvancedFilters.current = {...allSearchAdvancedFilters.current, type: searchAdvancedFiltersForm.type}; + prevSearchAdvancedFiltersFormsByType.current[searchAdvancedFiltersForm.type] = searchAdvancedFiltersForm; + + // Here we only rely on `searchAdvancedFiltersForm`, without triggering when `shouldUpdate`, + // because `shouldUpdate` is just a flag indicating that an update can happen, + // and the actual update only occurs when `searchAdvancedFiltersForm` has truly been updated. + // And since `shouldUpdate` is a value derived from queryJSON data, + // when `searchAdvancedFiltersForm` is updated via useOnyx, + // `shouldUpdate` has already been updated beforehand, + // so there’s no concern about having an incorrect value. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchAdvancedFiltersForm]); + + // We want to use ref here only because the value from this hook is just a derived value, + // and we don’t need to rerender when the hook’s value changes. + // eslint-disable-next-line react-hooks/refs + return shouldUpdate && isEmptyObject(allSearchAdvancedFilters.current) ? searchAdvancedFiltersForm : allSearchAdvancedFilters.current; +} diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index e58223e987fbc..04a580b44699b 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -65,6 +65,7 @@ import type { SearchTransactionAction, SearchWithdrawalIDGroup, } from '@src/types/onyx/SearchResults'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type IconAsset from '@src/types/utils/IconAsset'; import arraysEqual from '@src/utils/arraysEqual'; import {hasSynchronizationErrorMessage} from './actions/connections'; @@ -3914,20 +3915,19 @@ function updateQueryStringOnSearchTypeChange(type: SearchDataTypes, searchAdvanc }; // If the type has changed, reset the columns - if (updatedFilterFormValues.type !== searchAdvancedFiltersForm.type) { - const newStatus = []; + if (type !== searchAdvancedFiltersForm.type) { + // Filter Status options for current type const currentStatus = typeof updatedFilterFormValues.status === 'string' ? updatedFilterFormValues.status.split(',') : (updatedFilterFormValues.status ?? []); + const validStatusSet = new Set(getStatusOptions(() => '', type).map((option) => option.value)) as Set; + updatedFilterFormValues.status = currentStatus.filter((value) => validStatusSet.has(value)); + updatedFilterFormValues.status = isEmptyObject(updatedFilterFormValues.status) ? CONST.SEARCH.STATUS.EXPENSE.ALL : updatedFilterFormValues.status; - for (const status of currentStatus) { - if ( - (type === CONST.SEARCH.DATA_TYPES.EXPENSE && Object.values(CONST.SEARCH.STATUS.EXPENSE).includes(status)) || - (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && Object.values(CONST.SEARCH.STATUS.EXPENSE_REPORT).includes(status)) - ) { - newStatus.push(status); - } - } + // Filter Has options for current type + const currentHas = updatedFilterFormValues.has; + const validHasSet = new Set(getHasOptions(() => '', type).map((option) => option.value)) as Set; + updatedFilterFormValues.has = currentHas?.filter((value) => validHasSet.has(value)); + updatedFilterFormValues.has = isEmptyObject(updatedFilterFormValues.has) ? undefined : updatedFilterFormValues.has; - updatedFilterFormValues.status = newStatus.length === 0 ? CONST.SEARCH.STATUS.EXPENSE.ALL : newStatus; updatedFilterFormValues.columns = []; } diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx index 87bfadad15bf4..832daccbd3fac 100644 --- a/src/pages/Search/SearchTypeMenu.tsx +++ b/src/pages/Search/SearchTypeMenu.tsx @@ -20,6 +20,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; import useSingleExecution from '@hooks/useSingleExecution'; +import useStickySearchFilters from '@hooks/useStickySearchFilters'; import useSuggestedSearchDefaultNavigation from '@hooks/useSuggestedSearchDefaultNavigation'; import useThemeStyles from '@hooks/useThemeStyles'; import {setSearchContext} from '@libs/actions/Search'; @@ -32,9 +33,8 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {SearchAdvancedFiltersForm} from '@src/types/form'; import type {SaveSearchItem} from '@src/types/onyx/SaveSearch'; -import {getEmptyObject} from '@src/types/utils/EmptyObject'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import SavedSearchItemThreeDotMenu from './SavedSearchItemThreeDotMenu'; import SuggestedSearchSkeleton from './SuggestedSearchSkeleton'; @@ -80,7 +80,6 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { const taxRates = getAllTaxRates(allPolicies); const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector, canBeMissing: false}); const {clearSelectedTransactions} = useSearchContext(); - const [searchAdvancedFiltersForm = getEmptyObject>()] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {canBeMissing: true}); const flattenedMenuItems = useMemo(() => typeMenuSections.flatMap((section) => section.menuItems), [typeMenuSections]); @@ -217,6 +216,7 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { [similarSearchHash, isSavedSearchActive, flattenedMenuItems, queryJSON?.type], ); + const allSearchAdvancedFilters = useStickySearchFilters(isExploreSectionActive && !shouldShowSuggestedSearchSkeleton); return ( <> {CreateReportConfirmationModal} @@ -250,8 +250,8 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { setSearchContext(false); let queryString = item.searchQuery; - if (isExploreSectionActive && section.translationPath === 'common.explore') { - queryString = updateQueryStringOnSearchTypeChange(item.type, searchAdvancedFiltersForm, queryJSON); + if (section.translationPath === 'common.explore' && !isEmptyObject(allSearchAdvancedFilters)) { + queryString = updateQueryStringOnSearchTypeChange(item.type, allSearchAdvancedFilters, queryJSON); } Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryString})); }); From 4938e976f5227118dc5537d45c837ab613a6bd25 Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Wed, 28 Jan 2026 18:25:33 +0700 Subject: [PATCH 6/7] fix lint --- src/libs/SearchUIUtils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 04a580b44699b..b4c96c0cf0c85 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import {format} from 'date-fns'; import type {TextStyle, ViewStyle} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; From fd90e2d42387338597c19b249735a17d85d7a4fe Mon Sep 17 00:00:00 2001 From: dmkt9 Date: Thu, 5 Feb 2026 17:58:42 +0700 Subject: [PATCH 7/7] Add sticky filters to the reports page --- src/hooks/useStickySearchFilters.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/hooks/useStickySearchFilters.ts b/src/hooks/useStickySearchFilters.ts index 66708189d6e37..683d1d83261d3 100644 --- a/src/hooks/useStickySearchFilters.ts +++ b/src/hooks/useStickySearchFilters.ts @@ -1,7 +1,7 @@ -import {useEffect} from 'react'; +import {useMemo} from 'react'; import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; -import {getEmptyObject, isEmptyObject} from '@src/types/utils/EmptyObject'; +import {getEmptyObject} from '@src/types/utils/EmptyObject'; import useOnyx from './useOnyx'; const allSearchAdvancedFilters: {current: Partial} = {current: {}}; @@ -12,9 +12,9 @@ const prevSearchAdvancedFiltersFormsByType: {current: Record>()] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {canBeMissing: true}); - useEffect(() => { + const currentAllSearchAdvancedFilters = useMemo(() => { if (!shouldUpdate || !searchAdvancedFiltersForm.type) { - return; + return allSearchAdvancedFilters.current; } const prevSearchAdvancedFiltersForm = prevSearchAdvancedFiltersFormsByType.current[searchAdvancedFiltersForm.type]; @@ -43,6 +43,7 @@ export default function useStickySearchFilters(shouldUpdate?: boolean) { allSearchAdvancedFilters.current = {...allSearchAdvancedFilters.current, type: searchAdvancedFiltersForm.type}; prevSearchAdvancedFiltersFormsByType.current[searchAdvancedFiltersForm.type] = searchAdvancedFiltersForm; + return allSearchAdvancedFilters.current; // Here we only rely on `searchAdvancedFiltersForm`, without triggering when `shouldUpdate`, // because `shouldUpdate` is just a flag indicating that an update can happen, // and the actual update only occurs when `searchAdvancedFiltersForm` has truly been updated. @@ -53,8 +54,5 @@ export default function useStickySearchFilters(shouldUpdate?: boolean) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchAdvancedFiltersForm]); - // We want to use ref here only because the value from this hook is just a derived value, - // and we don’t need to rerender when the hook’s value changes. - // eslint-disable-next-line react-hooks/refs - return shouldUpdate && isEmptyObject(allSearchAdvancedFilters.current) ? searchAdvancedFiltersForm : allSearchAdvancedFilters.current; + return currentAllSearchAdvancedFilters; }