diff --git a/src/hooks/useSearchTypeMenu.tsx b/src/hooks/useSearchTypeMenu.tsx index 4f594ff6eb0fd..f456e19ffcb1f 100644 --- a/src/hooks/useSearchTypeMenu.tsx +++ b/src/hooks/useSearchTypeMenu.tsx @@ -11,19 +11,20 @@ import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; 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'; import ROUTES from '@src/ROUTES'; 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'; @@ -174,14 +175,12 @@ export default function useSearchTypeMenu(queryJSON: SearchQueryJSON) { translate, ]); - const activeItemIndex = useMemo(() => { - // If we have a suggested search, then none of the menu items are active - if (isSavedSearchActive) { - return -1; - } + const [activeItemIndex, isExploreSectionActive] = useMemo( + () => getActiveSearchItemIndex(flattenedMenuItems, similarSearchHash, isSavedSearchActive, queryJSON?.type), + [similarSearchHash, isSavedSearchActive, flattenedMenuItems, queryJSON?.type], + ); - return flattenedMenuItems.findIndex((item) => item.similarSearchHash === similarSearchHash); - }, [similarSearchHash, isSavedSearchActive, flattenedMenuItems]); + const allSearchAdvancedFilters = useStickySearchFilters(isExploreSectionActive && !shouldShowSuggestedSearchSkeleton); const popoverMenuItems = useMemo(() => { return typeMenuSections @@ -216,7 +215,12 @@ 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 (section.translationPath === 'common.explore' && !isEmptyObject(allSearchAdvancedFilters)) { + queryString = updateQueryStringOnSearchTypeChange(item.type, allSearchAdvancedFilters, queryJSON); + } + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryString})); }), }); } @@ -225,7 +229,7 @@ export default function useSearchTypeMenu(queryJSON: SearchQueryJSON) { return sectionItems; }) .flat(); - }, [typeMenuSections, translate, styles.textSupporting, savedSearchesMenuItems, activeItemIndex, theme.border, expensifyIcons, singleExecution]); + }, [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 59397c9f7c282..bcd64bb7ac77a 100644 --- a/src/hooks/useSearchTypeMenuSections.ts +++ b/src/hooks/useSearchTypeMenuSections.ts @@ -94,7 +94,7 @@ const useSearchTypeMenuSections = () => { }, [pendingReportCreation, openCreateReportConfirmation]); const isSuggestedSearchDataReady = useMemo(() => { - return Object.values(allPolicies ?? {}).some((policy) => policy?.employeeList !== undefined && policy?.exporter !== undefined); + return Object.values(allPolicies ?? {}).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..683d1d83261d3 --- /dev/null +++ b/src/hooks/useStickySearchFilters.ts @@ -0,0 +1,58 @@ +import {useMemo} from 'react'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {SearchAdvancedFiltersForm} from '@src/types/form'; +import {getEmptyObject} 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}); + + const currentAllSearchAdvancedFilters = useMemo(() => { + if (!shouldUpdate || !searchAdvancedFiltersForm.type) { + return allSearchAdvancedFilters.current; + } + + 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; + + 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. + // 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]); + + return currentAllSearchAdvancedFilters; +} diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 46b3267a2be44..f3389d86b4583 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -60,6 +60,7 @@ import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Route} 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'; @@ -77,6 +78,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'; @@ -4523,6 +4525,83 @@ function navigateToSearchRHP(route: {route: string; getRoute: (backTo?: string) } } +/** + * 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 (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; + + // 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.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; +} + function shouldShowDeleteOption( selectedTransactions: Record, currentSearchResults: SearchResults['data'] | undefined, @@ -4630,6 +4709,8 @@ export { getCustomColumns, getCustomColumnDefault, navigateToSearchRHP, + getActiveSearchItemIndex, + updateQueryStringOnSearchTypeChange, shouldShowDeleteOption, getToFieldValueForTransaction, isTodoSearch, diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx index cc673f3636363..b93a67e00e4b6 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'; @@ -27,12 +28,13 @@ import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; 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'; import ROUTES from '@src/ROUTES'; import type {SaveSearchItem} from '@src/types/onyx/SaveSearch'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import SavedSearchItemThreeDotMenu from './SavedSearchItemThreeDotMenu'; import SuggestedSearchSkeleton from './SuggestedSearchSkeleton'; @@ -224,15 +226,12 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { [expensifyIcons.Bookmark, styles.sectionMenuItem], ); - const activeItemIndex = useMemo(() => { - // If we have a suggested search, then none of the menu items are active - if (isSavedSearchActive) { - return -1; - } - - return flattenedMenuItems.findIndex((item) => item.similarSearchHash === similarSearchHash); - }, [similarSearchHash, isSavedSearchActive, flattenedMenuItems]); + const [activeItemIndex, isExploreSectionActive] = useMemo( + () => getActiveSearchItemIndex(flattenedMenuItems, similarSearchHash, isSavedSearchActive, queryJSON?.type), + [similarSearchHash, isSavedSearchActive, flattenedMenuItems, queryJSON?.type], + ); + const allSearchAdvancedFilters = useStickySearchFilters(isExploreSectionActive && !shouldShowSuggestedSearchSkeleton); return ( <> {CreateReportConfirmationModal} @@ -264,7 +263,12 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { const onPress = singleExecution(() => { clearSelectedTransactions(); setSearchContext(false); - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: item.searchQuery})); + let queryString = item.searchQuery; + + if (section.translationPath === 'common.explore' && !isEmptyObject(allSearchAdvancedFilters)) { + queryString = updateQueryStringOnSearchTypeChange(item.type, allSearchAdvancedFilters, queryJSON); + } + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryString})); }); return (