Skip to content
Merged
26 changes: 15 additions & 11 deletions src/hooks/useSearchTypeMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}));
}),
});
}
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useSearchTypeMenuSections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
58 changes: 58 additions & 0 deletions src/hooks/useStickySearchFilters.ts
Original file line number Diff line number Diff line change
@@ -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<SearchAdvancedFiltersForm>} = {current: {}};
const prevSearchAdvancedFiltersFormsByType: {current: Record<string, Partial<SearchAdvancedFiltersForm> | 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<Partial<SearchAdvancedFiltersForm>>()] = 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<keyof typeof searchAdvancedFiltersForm>;
const changedKeys: Array<keyof typeof searchAdvancedFiltersForm> = [];
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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think that we can use usePrevious for prevSearchAdvancedFiltersFormsByType so we can reuse existing hook?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initially, I also intended to use usePrevious, but I encountered some issues during implementation such as:

  • prevSearchAdvancedFiltersFormsByType needs to preserve its data, but using usePrevious inside our hook would cause prevSearchAdvancedFiltersFormsByType to reset if the component gets remounted. This is most noticeable on mobile devices because the menu is displayed through a popover modal.
  • Storing all previous values is more convenient for us to access. If we use usePrevious, we would need to split them into separate variables for each type, which increases complexity.


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;
}
81 changes: 81 additions & 0 deletions src/libs/SearchUIUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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<SearchAdvancedFiltersForm>, queryJSON: SearchQueryJSON | undefined): string {
const updatedFilterFormValues: Partial<SearchAdvancedFiltersForm> = {
...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<string>;
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<string>;
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<string, SelectedTransactionInfo>,
currentSearchResults: SearchResults['data'] | undefined,
Expand Down Expand Up @@ -4630,6 +4709,8 @@ export {
getCustomColumns,
getCustomColumnDefault,
navigateToSearchRHP,
getActiveSearchItemIndex,
updateQueryStringOnSearchTypeChange,
shouldShowDeleteOption,
getToFieldValueForTransaction,
isTodoSearch,
Expand Down
24 changes: 14 additions & 10 deletions src/pages/Search/SearchTypeMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,21 @@ 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 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';

Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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 (
Expand Down
Loading