diff --git a/src/hooks/useSearchTypeMenu.tsx b/src/hooks/useSearchTypeMenu.tsx index dc26046d64354..2c4a582ffb831 100644 --- a/src/hooks/useSearchTypeMenu.tsx +++ b/src/hooks/useSearchTypeMenu.tsx @@ -9,16 +9,22 @@ import ThreeDotsMenu from '@components/ThreeDotsMenu'; import {setSearchContext} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; -import {buildSearchQueryJSON, buildUserReadableQueryString, shouldSkipSuggestedSearchNavigation as shouldSkipSuggestedSearchNavigationForQuery} from '@libs/SearchQueryUtils'; +import { + buildSearchQueryJSON, + buildSearchQueryString, + buildUserReadableQueryString, + shouldSkipSuggestedSearchNavigation as shouldSkipSuggestedSearchNavigationForQuery, +} from '@libs/SearchQueryUtils'; import type {SavedSearchMenuItem} from '@libs/SearchUIUtils'; -import {createBaseSavedSearchMenuItem, getItemBadgeText, getOverflowMenu as getOverflowMenuUtil} from '@libs/SearchUIUtils'; +import {createBaseSavedSearchMenuItem, getActiveSearchItemIndex, getItemBadgeText, 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 todosReportCountsSelector from '@src/selectors/Todos'; import type {Report} from '@src/types/onyx'; -import {getEmptyObject} from '@src/types/utils/EmptyObject'; +import {getEmptyObject, isEmptyObject} from '@src/types/utils/EmptyObject'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import useDeleteSavedSearch from './useDeleteSavedSearch'; import useFeedKeysWithAssignedCards from './useFeedKeysWithAssignedCards'; import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; @@ -26,6 +32,7 @@ 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'; @@ -48,7 +55,8 @@ export default function useSearchTypeMenu(queryJSON: SearchQueryJSON) { const [reports = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const taxRates = getAllTaxRates(allPolicies); const [personalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST); - const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); + const [savedSearches, savedSearchesMetadata] = useOnyx(ONYXKEYS.SAVED_SEARCHES); + const isLoadingSavedSearches = isLoadingOnyxValue(savedSearchesMetadata); const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const [reportCounts = CONST.EMPTY_TODOS_REPORT_COUNTS] = useOnyx(ONYXKEYS.DERIVED.TODOS, {selector: todosReportCountsSelector}); const expensifyIcons = useMemoizedLazyExpensifyIcons([ @@ -181,14 +189,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 && !isLoadingSavedSearches); const popoverMenuItems = useMemo(() => { return typeMenuSections @@ -223,7 +229,16 @@ 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); + // If the focused menu item is not in the explore section, but the queryString matches the current one, it will fall back to the default value + if (!isExploreSectionActive && queryString === buildSearchQueryString(queryJSON)) { + queryString = item.searchQuery; + } + } + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryString})); }), }); } @@ -232,7 +247,20 @@ export default function useSearchTypeMenu(queryJSON: SearchQueryJSON) { return sectionItems; }) .flat(); - }, [typeMenuSections, translate, styles.textSupporting, savedSearchesMenuItems, activeItemIndex, theme.border, expensifyIcons, singleExecution, reportCounts]); + }, [ + typeMenuSections, + translate, + styles.textSupporting, + savedSearchesMenuItems, + activeItemIndex, + expensifyIcons, + theme.border, + singleExecution, + reportCounts, + allSearchAdvancedFilters, + queryJSON, + isExploreSectionActive, + ]); const openMenu = useCallback(() => { setIsPopoverVisible(true); diff --git a/src/hooks/useSearchTypeMenuSections.ts b/src/hooks/useSearchTypeMenuSections.ts index 5a0fafb6df478..8b1aad3007478 100644 --- a/src/hooks/useSearchTypeMenuSections.ts +++ b/src/hooks/useSearchTypeMenuSections.ts @@ -91,7 +91,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..4dd9491309303 --- /dev/null +++ b/src/hooks/useStickySearchFilters.ts @@ -0,0 +1,69 @@ +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: {}}; + +/** + * Reset all values of the sticky search filters + */ +function resetStickySearchFiltersValues() { + allSearchAdvancedFilters.current = {}; + prevSearchAdvancedFiltersFormsByType.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); + + 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; +} + +export {resetStickySearchFiltersValues}; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index d80a5012ed530..8d85b21631259 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -62,6 +62,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'; @@ -79,6 +80,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'; @@ -4368,6 +4370,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, @@ -4474,6 +4553,8 @@ export { getCustomColumnDefault, filterValidHasValues, navigateToSearchRHP, + getActiveSearchItemIndex, + updateQueryStringOnSearchTypeChange, shouldShowDeleteOption, getToFieldValueForTransaction, isTodoSearch, diff --git a/src/libs/actions/Delegate.ts b/src/libs/actions/Delegate.ts index 8e45d3c29433e..9590802e104ad 100644 --- a/src/libs/actions/Delegate.ts +++ b/src/libs/actions/Delegate.ts @@ -1,6 +1,7 @@ import HybridAppModule from '@expensify/react-native-hybrid-app'; import Onyx from 'react-native-onyx'; import type {OnyxEntry, OnyxKey, OnyxUpdate} from 'react-native-onyx'; +import {resetStickySearchFiltersValues} from '@hooks/useStickySearchFilters'; import * as API from '@libs/API'; import type {AddDelegateParams as APIAddDelegateParams, RemoveDelegateParams as APIRemoveDelegateParams, UpdateDelegateRoleParams as APIUpdateDelegateRoleParams} from '@libs/API/parameters'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; @@ -186,6 +187,7 @@ function connect({email, delegatedAccess, credentials, session, activePolicyID, return; } clearPreservedSearchNavigatorStates(); + resetStickySearchFiltersValues(); const restrictedToken = response.restrictedToken; const policyID = activePolicyID; @@ -275,6 +277,7 @@ function disconnect({stashedCredentials, stashedSession}: DisconnectParams) { } clearPreservedSearchNavigatorStates(); + resetStickySearchFiltersValues(); const requesterEmail = response.requesterEmail; const authToken = response.authToken; diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 7ebdac9a989fc..c293dda8c9b52 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -6,6 +6,7 @@ import type {ChannelAuthorizationCallback} from 'pusher-js/with-encryption'; import {InteractionManager} from 'react-native'; import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import {resetStickySearchFiltersValues} from '@hooks/useStickySearchFilters'; import {buildOldDotURL, openExternalLink} from '@libs/actions/Link'; import * as PersistedRequests from '@libs/actions/PersistedRequests'; import * as API from '@libs/API'; @@ -249,6 +250,7 @@ function signOut(params: {autoGeneratedLogin?: string; signedInWithSAML?: boolea skipReauthentication: true, }; + resetStickySearchFiltersValues(); if (params.signedInWithSAML && params.authToken) { return callSAMLSignOut(logOutParams, params.authToken); } diff --git a/src/pages/Search/SearchAdvancedFiltersPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage.tsx index d8dd00c2e6ade..aacaab3b75ff7 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage.tsx @@ -4,6 +4,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import TextLink from '@components/TextLink'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {resetStickySearchFiltersValues} from '@hooks/useStickySearchFilters'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearAdvancedFilters} from '@libs/actions/Search'; import CONST from '@src/CONST'; @@ -44,7 +45,16 @@ function SearchAdvancedFiltersPage() { includeSafeAreaPaddingBottom > - {shouldShowResetFilters && {translate('search.resetFilters')}} + {shouldShowResetFilters && ( + { + resetStickySearchFiltersValues(); + clearAdvancedFilters(); + }} + > + {translate('search.resetFilters')} + + )} diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx index 3c4572d2e18a7..85a7539b5ce40 100644 --- a/src/pages/Search/SearchTypeMenu.tsx +++ b/src/pages/Search/SearchTypeMenu.tsx @@ -21,20 +21,28 @@ 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'; import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; -import {buildSearchQueryJSON, buildUserReadableQueryString, shouldSkipSuggestedSearchNavigation as shouldSkipSuggestedSearchNavigationForQuery} from '@libs/SearchQueryUtils'; +import { + buildSearchQueryJSON, + buildSearchQueryString, + buildUserReadableQueryString, + shouldSkipSuggestedSearchNavigation as shouldSkipSuggestedSearchNavigationForQuery, +} from '@libs/SearchQueryUtils'; import type {SavedSearchMenuItem} from '@libs/SearchUIUtils'; -import {createBaseSavedSearchMenuItem, getItemBadgeText, getOverflowMenu as getOverflowMenuUtil} from '@libs/SearchUIUtils'; +import {createBaseSavedSearchMenuItem, getActiveSearchItemIndex, getItemBadgeText, 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 todosReportCountsSelector from '@src/selectors/Todos'; import type {SaveSearchItem} from '@src/types/onyx/SaveSearch'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import SavedSearchItemThreeDotMenu from './SavedSearchItemThreeDotMenu'; import SuggestedSearchSkeleton from './SuggestedSearchSkeleton'; @@ -49,7 +57,8 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { const styles = useThemeStyles(); const {singleExecution} = useSingleExecution(); const {translate} = useLocalize(); - const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); + const [savedSearches, savedSearchesMetadata] = useOnyx(ONYXKEYS.SAVED_SEARCHES); + const isLoadingSavedSearches = isLoadingOnyxValue(savedSearchesMetadata); const {typeMenuSections, CreateReportConfirmationModal, shouldShowSuggestedSearchSkeleton} = useSearchTypeMenuSections(); const isFocused = useIsFocused(); const { @@ -232,15 +241,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 && !isLoadingSavedSearches); return ( <> {CreateReportConfirmationModal} @@ -277,7 +283,16 @@ 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); + // If the focused menu item is not in the explore section, but the queryString matches the current one, it will fall back to the default value + if (!isExploreSectionActive && queryString === buildSearchQueryString(queryJSON)) { + queryString = item.searchQuery; + } + } + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryString})); }); return (