diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 12ab2cc54daf..81c1964fc49f 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8910,7 +8910,7 @@ const CONST = { TYPE_MENU_ITEM: 'Search-TypeMenuItem', SAVED_SEARCH_MENU_ITEM: 'Search-SavedSearchMenuItem', SAVE_VIEW_BUTTON: 'Search-SaveViewButton', - CLEAR_FILTERS_BUTTON: 'Search-ClearFiltersButton', + RESET_FILTERS_BUTTON: 'Search-ResetFiltersButton', ACTION_CELL_VIEW: 'Search-ActionCellView', ACTION_CELL_PAY: 'Search-ActionCellPay', ACTION_CELL_ACTION: 'Search-ActionCellAction', @@ -9755,18 +9755,6 @@ const SUBMIT_FEATURE_IDS: ReadonlySet = new Set([ CONST.UPGRADE_FEATURE_INTRO_MAPPING.invoicing.id, ]); -type SearchFilterKey = ValueOf | ValueOf; - -const CONTINUATION_DETECTION_SEARCH_FILTER_KEYS = [ - CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, - CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, - CONST.SEARCH.SYNTAX_FILTER_KEYS.ASSIGNEE, - CONST.SEARCH.SYNTAX_FILTER_KEYS.PAYER, - CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_BY, - CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTER, - CONST.SEARCH.SYNTAX_FILTER_KEYS.ATTENDEE, -] as SearchFilterKey[]; - const FRAUD_PROTECTION_EVENT = { START_SUPPORT_SESSION: 'StartSupportSession', STOP_SUPPORT_SESSION: 'StopSupportSession', @@ -9835,6 +9823,6 @@ export type { EnablePaymentsSubPageType, }; -export {CONTINUATION_DETECTION_SEARCH_FILTER_KEYS, FRAUD_PROTECTION_EVENT, COUNTRIES_US_BANK_FLOW, SUBMIT_FEATURE_IDS}; +export {FRAUD_PROTECTION_EVENT, COUNTRIES_US_BANK_FLOW, SUBMIT_FEATURE_IDS}; export default CONST; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index f9fa46cceb69..5fecf64c7d8e 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -719,6 +719,9 @@ const ONYXKEYS = { /** Stores the information about the recent searches */ RECENT_SEARCHES: 'nvp_recentSearches', + /** Stores the last query for each suggested/saved search */ + SEARCH_FILTERS: 'nvp_searchFilters', + /** Stores the current search page context (e.g., whether to show the search query) */ SEARCH_CONTEXT: 'searchContext', @@ -1581,6 +1584,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_TRY_NEW_DOT]: OnyxTypes.TryNewDot; [ONYXKEYS.RECENT_SEARCHES]: Record; [ONYXKEYS.SAVED_SEARCHES]: OnyxTypes.SaveSearch; + [ONYXKEYS.SEARCH_FILTERS]: OnyxTypes.SearchFilters; [ONYXKEYS.NVP_HAS_SEEDED_MY_EXPENSES_SEARCH]: boolean; [ONYXKEYS.SEARCH_CONTEXT]: OnyxTypes.SearchContext; [ONYXKEYS.SEARCH_FOOTER_CONVERSION]: OnyxTypes.SearchFooterConversion; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index a2377a2bff95..364e12f77625 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1958,8 +1958,8 @@ const ROUTES = { }, SEARCH_SAVE: 'search/save', SEARCH_SAVED_SEARCH_RENAME: { - route: 'search/saved-search/rename', - getRoute: ({name, jsonQuery}: {name: string; jsonQuery: SearchQueryString}) => `search/saved-search/rename?name=${name}&q=${encodeURIComponent(jsonQuery)}` as const, + route: 'search/saved-search/rename/:id', + getRoute: (id: string) => `search/saved-search/rename/${id}` as const, }, SEARCH_COLUMNS: 'search/columns', SEARCH_ADVANCED_FILTERS: 'search/filters', diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index 04c07775ad06..2b10fca04c8c 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -165,7 +165,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport); const isAnyTransactionOnHold = hasHeldExpensesReportUtils(allTransactions); diff --git a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx index be80c5e50e06..e3d8c55de5f2 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx @@ -116,7 +116,7 @@ function PayPrimaryAction({reportID, chatReportID}: PayPrimaryActionProps) { const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); const {openHoldMenu} = useMoneyReportHeaderModals(); diff --git a/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx index 1499234134f7..baca689035c1 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx @@ -130,7 +130,7 @@ function SubmitPrimaryActionContent({reportID}: SubmitPrimaryActionProps) { const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Send', 'Document']); diff --git a/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx b/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx index 3cf63c6ea929..72308f304f1f 100644 --- a/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx +++ b/src/components/Navigation/NavigationTabBar/SearchTabButton.tsx @@ -15,6 +15,7 @@ import navigationRef from '@navigation/navigationRef'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {lastExpensesSearchQuerySelector} from '@src/selectors/SearchFilters'; import type {ValueOf} from 'type-fest'; @@ -34,6 +35,7 @@ function SearchTabButton({selectedTab, isWideLayout}: SearchTabButtonProps) { const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['ReceiptMultiple']); const [lastSearchParams] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY); + const [lastExpensesSearchQuery] = useOnyx(ONYXKEYS.SEARCH_FILTERS, {selector: lastExpensesSearchQuerySelector}); const searchAccessibilityState = {selected: selectedTab === NAVIGATION_TABS.SEARCH}; const navigateToSearch = () => { @@ -49,7 +51,7 @@ function SearchTabButton({selectedTab, isWideLayout}: SearchTabButtonProps) { }); startNavigateToReportsSpans(); - Navigation.navigate(getSearchTabRoute(navigationRef.getRootState(), lastSearchParams)); + Navigation.navigate(getSearchTabRoute(navigationRef.getRootState(), lastSearchParams, lastExpensesSearchQuery)); }); }; diff --git a/src/components/Navigation/NavigationTabBar/getLastRoute.ts b/src/components/Navigation/NavigationTabBar/getLastRoute.ts index d96583699e84..a78a8b17c217 100644 --- a/src/components/Navigation/NavigationTabBar/getLastRoute.ts +++ b/src/components/Navigation/NavigationTabBar/getLastRoute.ts @@ -17,13 +17,13 @@ function getLastRoute(rootState: NavigationState, navigator: ValueOf tabRoute.state && tabRoute.name === navigator); }); if (!rootTabRoute) { rootTabRoute = rootState.routes.findLast((route) => route.name === NAVIGATORS.TAB_NAVIGATOR); } const tabState = getTabState(rootTabRoute); - lastNavigatorKey = tabState?.routes?.findLast((route) => route.name === navigator)?.key; + lastNavigatorKey = tabState?.routes?.findLast((route) => route.state && route.name === navigator)?.key; } const lastNavigatorState = lastNavigatorKey ? getPreservedNavigatorState(lastNavigatorKey) : undefined; diff --git a/src/components/Navigation/NavigationTabBar/getSearchTabRoute.ts b/src/components/Navigation/NavigationTabBar/getSearchTabRoute.ts index 2ec0662b726a..53ef70903741 100644 --- a/src/components/Navigation/NavigationTabBar/getSearchTabRoute.ts +++ b/src/components/Navigation/NavigationTabBar/getSearchTabRoute.ts @@ -1,7 +1,7 @@ /** * Resolves the route used to restore the latest Spend search. */ -import {buildCannedSearchQuery, buildSearchQueryJSON, buildSearchQueryString, isSearchRootParams} from '@libs/SearchQueryUtils'; +import {buildCannedSearchQuery, buildSearchQueryJSON, buildSearchQueryString, getValidLastQuery, isSearchRootParams} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; @@ -14,7 +14,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import getLastRoute from './getLastRoute'; -function getSearchTabRoute(rootState: NavigationState, lastSearchParams: OnyxEntry) { +function getSearchTabRoute(rootState: NavigationState, lastSearchParams: OnyxEntry, lastExpensesSearchQuery: string | undefined) { const lastSearchRoute = getLastRoute(rootState, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.SEARCH.ROOT); if (isSearchRootParams(lastSearchRoute?.params)) { @@ -31,7 +31,8 @@ function getSearchTabRoute(rootState: NavigationState, lastSearchParams: OnyxEnt const lastQueryJSON = lastSearchParams?.queryJSON; const lastQueryFromOnyx = lastQueryJSON ? buildSearchQueryString(lastQueryJSON) : undefined; const defaultSearchQuery = buildCannedSearchQuery({type: CONST.SEARCH.DATA_TYPES.EXPENSE}); - return ROUTES.SEARCH_ROOT.getRoute({query: lastQueryFromOnyx ?? defaultSearchQuery}); + const fallbackSearchQuery = getValidLastQuery(lastExpensesSearchQuery, defaultSearchQuery); + return ROUTES.SEARCH_ROOT.getRoute({query: lastQueryFromOnyx ?? fallbackSearchQuery}); } export default getSearchTabRoute; diff --git a/src/components/Navigation/SearchSidebar.tsx b/src/components/Navigation/SearchSidebar.tsx index 032b663ea4e2..c0d7d37e9242 100644 --- a/src/components/Navigation/SearchSidebar.tsx +++ b/src/components/Navigation/SearchSidebar.tsx @@ -4,7 +4,7 @@ import SidebarRightIcon from '@assets/images/sidebar-right.svg'; import Hoverable from '@components/Hoverable'; import Icon from '@components/Icon'; import {PressableWithoutFeedback} from '@components/Pressable'; -import {useSearchQueryContext, useSearchResultsActions, useSearchResultsContext} from '@components/Search/SearchContext'; +import {useSearchResultsActions, useSearchResultsContext} from '@components/Search/SearchContext'; import Tooltip from '@components/Tooltip'; import {useLoadingBarVisibility} from '@hooks/useInFlightRequests'; @@ -57,7 +57,6 @@ function SearchSidebar({state}: SearchSidebarProps) { const route = state.routes.at(-1); const {lastSearchType, currentSearchResults} = useSearchResultsContext(); - const {currentSearchQueryJSON} = useSearchQueryContext(); const {setLastSearchType} = useSearchResultsActions(); const searchType = currentSearchResults?.search?.type; @@ -122,7 +121,7 @@ function SearchSidebar({state}: SearchSidebarProps) { - + diff --git a/src/components/Search/FilterDropdowns/BasePopup.tsx b/src/components/Search/FilterDropdowns/BasePopup.tsx index 49b08cd08c76..4ab4b42fd48b 100644 --- a/src/components/Search/FilterDropdowns/BasePopup.tsx +++ b/src/components/Search/FilterDropdowns/BasePopup.tsx @@ -14,6 +14,7 @@ import ActionButtons from './ActionButtons'; type BasePopupProps = React.PropsWithChildren & { label?: string; showLabel?: boolean; + shouldShowActionButtons?: boolean; applySentryLabel: string; resetSentryLabel?: string; style?: StyleProp; @@ -22,7 +23,7 @@ type BasePopupProps = React.PropsWithChildren & { onBackButtonPress?: () => void; }; -function BasePopup({children, label, applySentryLabel, resetSentryLabel, showLabel, style, onApply, onReset, onBackButtonPress}: BasePopupProps) { +function BasePopup({children, label, applySentryLabel, resetSentryLabel, showLabel, shouldShowActionButtons = true, style, onApply, onReset, onBackButtonPress}: BasePopupProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); const styles = useThemeStyles(); @@ -42,13 +43,15 @@ function BasePopup({children, label, applySentryLabel, resetSentryLabel, showLab shouldDisplayLabel && {label} )} {children} - + {shouldShowActionButtons && ( + + )} ); } diff --git a/src/components/Search/FilterDropdowns/ListPopup.tsx b/src/components/Search/FilterDropdowns/ListPopup.tsx index b4f9c36f304d..4c38b92db059 100644 --- a/src/components/Search/FilterDropdowns/ListPopup.tsx +++ b/src/components/Search/FilterDropdowns/ListPopup.tsx @@ -14,13 +14,14 @@ import type {PopoverComponentProps} from './FilterPopupButton'; import BasePopup from './BasePopup'; type ListPopupProps = Pick & { + isDefault: boolean; values: Partial | undefined; label: string; closeOverlay: PopoverComponentProps['closeOverlay']; updateFilterForm: (value: Partial) => void; }; -function ListPopup({baseFilterKey, values, label, updateFilterForm, closeOverlay}: ListPopupProps) { +function ListPopup({baseFilterKey, isDefault, values, label, updateFilterForm, closeOverlay}: ListPopupProps) { const {isNegated: initialIsNegated, value: initialValue} = getFilterNegatableValue(baseFilterKey, values); const [value, setValue] = useState(initialValue); const [isNegated, setIsNegated] = useState(initialIsNegated); @@ -34,6 +35,7 @@ function ListPopup({baseFilterKey, values, label, updateFilterForm, closeOverlay (), suggestedSearches: {} as Record, shouldResetSearchQuery: false, }; const defaultSearchQueryActions: SearchQueryActionsValue = { setShouldResetSearchQuery: () => {}, + setCurrentSearchKey: () => {}, + resetSearchKey: () => {}, }; const EMPTY_TRANSACTIONS_BY_REPORT_ID: SearchResultsContextValue['currentSearchTransactionsByReportID'] = new Map(); diff --git a/src/components/Search/SearchPageHeader/SearchFiltersBarNarrow.tsx b/src/components/Search/SearchPageHeader/SearchFiltersBarNarrow.tsx index 89ded4f2cc97..5760b08daadb 100644 --- a/src/components/Search/SearchPageHeader/SearchFiltersBarNarrow.tsx +++ b/src/components/Search/SearchPageHeader/SearchFiltersBarNarrow.tsx @@ -12,7 +12,7 @@ import {FlatList} from 'react-native'; import type {FilterItem} from './useSearchFiltersBar'; import SearchFilterBar from './SearchFilterBar'; -import SearchFiltersClearButton from './SearchFiltersClearButton'; +import SearchFiltersResetButton from './SearchFiltersResetButton'; import useSearchFiltersBar from './useSearchFiltersBar'; type SearchFiltersBarNarrowProps = { @@ -22,7 +22,7 @@ type SearchFiltersBarNarrowProps = { function SearchFiltersBarNarrow({queryJSON}: SearchFiltersBarNarrowProps) { const styles = useThemeStyles(); const scrollRef = useRef>(null); - const {filters, hasErrors, shouldShowFiltersBarLoading, clearFilters} = useSearchFiltersBar(queryJSON); + const {filters, hasErrors, shouldShowFiltersBarLoading, shouldShowResetFilters, resetFilters} = useSearchFiltersBar(queryJSON); const adjustScroll = (info: {distanceFromEnd: number}) => { // Workaround for a known React Native bug on Android (https://github.com/facebook/react-native/issues/27504): @@ -59,7 +59,7 @@ function SearchFiltersBarNarrow({queryJSON}: SearchFiltersBarNarrowProps) { renderItem={renderFilterItem} onEndReached={adjustScroll} onEndReachedThreshold={0.75} - ListFooterComponent={filters.length > 0 ? : undefined} + ListFooterComponent={shouldShowResetFilters ? : undefined} /> ); } diff --git a/src/components/Search/SearchPageHeader/SearchFiltersBarWide.tsx b/src/components/Search/SearchPageHeader/SearchFiltersBarWide.tsx index cc54717a739c..34179ef8c480 100644 --- a/src/components/Search/SearchPageHeader/SearchFiltersBarWide.tsx +++ b/src/components/Search/SearchPageHeader/SearchFiltersBarWide.tsx @@ -4,7 +4,7 @@ import SearchFiltersSkeleton from '@components/Skeletons/SearchFiltersSkeleton'; import React from 'react'; import SearchFilterBar from './SearchFilterBar'; -import SearchFiltersClearButton from './SearchFiltersClearButton'; +import SearchFiltersResetButton from './SearchFiltersResetButton'; import useSearchFiltersBar from './useSearchFiltersBar'; type SearchFiltersBarWideProps = { @@ -12,7 +12,7 @@ type SearchFiltersBarWideProps = { }; function SearchFiltersBarWide({queryJSON}: SearchFiltersBarWideProps) { - const {filters, hasErrors, shouldShowFiltersBarLoading, clearFilters} = useSearchFiltersBar(queryJSON); + const {filters, hasErrors, shouldShowFiltersBarLoading, shouldShowResetFilters, resetFilters} = useSearchFiltersBar(queryJSON); if (hasErrors) { return null; @@ -30,7 +30,7 @@ function SearchFiltersBarWide({queryJSON}: SearchFiltersBarWideProps) { item={item} /> ))} - {filters.length > 0 && } + {shouldShowResetFilters && } ); } diff --git a/src/components/Search/SearchPageHeader/SearchFiltersClearButton.tsx b/src/components/Search/SearchPageHeader/SearchFiltersResetButton.tsx similarity index 66% rename from src/components/Search/SearchPageHeader/SearchFiltersClearButton.tsx rename to src/components/Search/SearchPageHeader/SearchFiltersResetButton.tsx index 6376bae4c5a2..6f8414f201cc 100644 --- a/src/components/Search/SearchPageHeader/SearchFiltersClearButton.tsx +++ b/src/components/Search/SearchPageHeader/SearchFiltersResetButton.tsx @@ -11,32 +11,32 @@ import CONST from '@src/CONST'; import React from 'react'; -type SearchFiltersClearButtonProps = { +type SearchFiltersResetButtonProps = { onPress: () => void; }; -function SearchFiltersClearButton({onPress}: SearchFiltersClearButtonProps) { +function SearchFiltersResetButton({onPress}: SearchFiltersResetButtonProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Close']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['RotateLeft']); return ( - {translate('common.clear')} + {translate('common.reset')} ); } -export default SearchFiltersClearButton; +export default SearchFiltersResetButton; diff --git a/src/components/Search/SearchPageHeader/SearchPageHeaderCommon.tsx b/src/components/Search/SearchPageHeader/SearchPageHeaderCommon.tsx new file mode 100644 index 000000000000..ac20d1781521 --- /dev/null +++ b/src/components/Search/SearchPageHeader/SearchPageHeaderCommon.tsx @@ -0,0 +1,34 @@ +import TopBar from '@components/Navigation/TopBar'; +import {useSearchQueryContext} from '@components/Search/SearchContext'; + +import useActiveSavedSearch from '@hooks/useActiveSavedSearch'; +import useLocalize from '@hooks/useLocalize'; +import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; + +import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; + +import getSearchPageHeaderTitle from './getSearchPageHeaderTitle'; + +type SearchPageHeaderCommonProps = { + queryJSONType: SearchDataTypes; + shouldShowLoadingBar?: boolean; +}; + +function SearchPageHeaderCommon({queryJSONType, shouldShowLoadingBar}: SearchPageHeaderCommonProps) { + const {translate} = useLocalize(); + const typeMenuSections = useSearchTypeMenuSections(); + const {currentSearchKey} = useSearchQueryContext(); + const selectedItem = typeMenuSections.flatMap((section) => section.menuItems).find((item) => item.key === currentSearchKey); + const activeSavedSearch = useActiveSavedSearch(); + const title = getSearchPageHeaderTitle({translate, type: queryJSONType, activeSavedSearch, selectedItem}); + + return ( + + ); +} + +export default SearchPageHeaderCommon; diff --git a/src/components/Search/SearchPageHeader/SearchPageHeaderNarrow.tsx b/src/components/Search/SearchPageHeader/SearchPageHeaderNarrow.tsx index a7fb1c7671b4..ec6970e40954 100644 --- a/src/components/Search/SearchPageHeader/SearchPageHeaderNarrow.tsx +++ b/src/components/Search/SearchPageHeader/SearchPageHeaderNarrow.tsx @@ -1,14 +1,10 @@ -import TopBar from '@components/Navigation/TopBar'; import type {SearchQueryJSON} from '@components/Search/types'; -import useLocalize from '@hooks/useLocalize'; -import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; - import SearchSelectedNarrow from '@pages/Search/SearchSelectedNarrow'; import React from 'react'; -import getSearchPageHeaderTitle from './getSearchPageHeaderTitle'; +import SearchPageHeaderCommon from './SearchPageHeaderCommon'; type SearchPageHeaderNarrowProps = { queryJSON: SearchQueryJSON; @@ -17,21 +13,14 @@ type SearchPageHeaderNarrowProps = { }; function SearchPageHeaderNarrow({queryJSON, shouldShowLoadingBar = false, isMobileSelectionModeEnabled}: SearchPageHeaderNarrowProps) { - const {translate} = useLocalize(); - const {typeMenuSections, activeItemIndex, activeSavedSearch} = useSearchTypeMenuSections(queryJSON); - const selectedItem = activeItemIndex >= 0 ? typeMenuSections.flatMap((section) => section.menuItems).at(activeItemIndex) : undefined; - - const title = getSearchPageHeaderTitle({translate, type: queryJSON.type, activeSavedSearch, selectedItem}); - if (isMobileSelectionModeEnabled) { return ; } return ( - ); } diff --git a/src/components/Search/SearchPageHeader/SearchPageHeaderWide.tsx b/src/components/Search/SearchPageHeader/SearchPageHeaderWide.tsx index 1acb6d1d7581..5def4ef20f54 100644 --- a/src/components/Search/SearchPageHeader/SearchPageHeaderWide.tsx +++ b/src/components/Search/SearchPageHeader/SearchPageHeaderWide.tsx @@ -1,31 +1,15 @@ -import TopBar from '@components/Navigation/TopBar'; import type {SearchQueryJSON} from '@components/Search/types'; -import useLocalize from '@hooks/useLocalize'; -import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; - import React from 'react'; -import getSearchPageHeaderTitle from './getSearchPageHeaderTitle'; +import SearchPageHeaderCommon from './SearchPageHeaderCommon'; type SearchPageHeaderWideProps = { queryJSON: SearchQueryJSON; }; function SearchPageHeaderWide({queryJSON}: SearchPageHeaderWideProps) { - const {translate} = useLocalize(); - const {typeMenuSections, activeItemIndex, activeSavedSearch} = useSearchTypeMenuSections(queryJSON); - const selectedItem = activeItemIndex >= 0 ? typeMenuSections.flatMap((section) => section.menuItems).at(activeItemIndex) : undefined; - - const title = getSearchPageHeaderTitle({translate, type: queryJSON.type, activeSavedSearch, selectedItem}); - - return ( - - ); + return ; } export default SearchPageHeaderWide; diff --git a/src/components/Search/SearchPageHeader/getSearchPageHeaderTitle.ts b/src/components/Search/SearchPageHeader/getSearchPageHeaderTitle.ts index 8c157982b04b..afa0ebb2edad 100644 --- a/src/components/Search/SearchPageHeader/getSearchPageHeaderTitle.ts +++ b/src/components/Search/SearchPageHeader/getSearchPageHeaderTitle.ts @@ -12,10 +12,10 @@ type SearchPageHeaderTitleDeps = { /** The `type` of the current search query, used for the data-type fallbacks */ type: SearchDataTypes | undefined; - /** The saved search the current query maps to, if any (from `useSearchTypeMenuSections`) */ + /** The saved search the current query maps to, if any */ activeSavedSearch: SaveSearchItem | undefined; - /** The matched suggested-search menu item, if any (only pass when `activeItemIndex >= 0`) */ + /** The matched suggested-search menu item, if any */ selectedItem: SearchTypeMenuItem | undefined; }; diff --git a/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx b/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx index ecfbbd72163c..dad5542ab987 100644 --- a/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx +++ b/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx @@ -5,8 +5,8 @@ import ListPopup from '@components/Search/FilterDropdowns/ListPopup'; import ReportFieldPopup from '@components/Search/FilterDropdowns/ReportFieldPopup'; import TextFilterPopup from '@components/Search/FilterDropdowns/TextFilterPopup'; import useUpdateFilterQuery from '@components/Search/hooks/useUpdateFilterQuery'; -import {useSearchResultsContext} from '@components/Search/SearchContext'; -import type {ReportFieldKey, SearchFilterKey, SearchQueryJSON} from '@components/Search/types'; +import {useSearchQueryContext, useSearchResultsContext} from '@components/Search/SearchContext'; +import type {SearchFilterKey, SearchQueryJSON} from '@components/Search/types'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; @@ -16,7 +16,8 @@ import {shouldShowInitialCategoryFilterLoading} from '@hooks/useSearchFilterSync import {close} from '@libs/actions/Modal'; import {setSearchContext} from '@libs/actions/Search'; -import {getAdvancedFiltersToReset, removeNegation} from '@libs/SearchQueryUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {buildQueryStringWithResetFilters, hasFiltersChangedFromDefault, removeNegation} from '@libs/SearchQueryUtils'; import {FILTER_VIEW_MAP, isAmountFilterKey, isDateFilterKey, isReportFieldKey, isTextFilterKey, mapFiltersFormToLabelValueList, SKIPPED_SEARCH_FILTERS} from '@libs/SearchUIUtils'; import type {SearchFilter} from '@libs/SearchUIUtils'; @@ -35,29 +36,31 @@ import DatePickerFilterPopup from './DatePickerFilterPopup'; type FilterItem = WithSentryLabel & { PopoverComponent: (props: PopoverComponentProps) => ReactNode; - onClosePress: () => void; + onClosePress: (() => void) | undefined; }; type UseSearchFiltersBarResult = { filters: Array; hasErrors: boolean; shouldShowFiltersBarLoading: boolean; - clearFilters: () => void; + shouldShowResetFilters: boolean; + resetFilters: () => void; }; type FilterPopupProps = { baseFilterKey: SearchFilter['key']; + isDefault: boolean; searchAdvancedFiltersForm: Partial; closeOverlay: () => void; setPopoverWidth: PopoverComponentProps['setPopoverWidth']; updateFilterForm: (values: Partial) => void; }; -function getFilterSentryLabel(filterKey: SearchAdvancedFiltersKey | SearchFilterKey | ReportFieldKey) { +function getFilterSentryLabel(filterKey: SearchAdvancedFiltersKey | SearchFilterKey) { return `Search-Filter-${filterKey}`; } -function FilterPopup({baseFilterKey, searchAdvancedFiltersForm, closeOverlay, setPopoverWidth, updateFilterForm}: FilterPopupProps) { +function FilterPopup({baseFilterKey, isDefault, searchAdvancedFiltersForm, closeOverlay, setPopoverWidth, updateFilterForm}: FilterPopupProps) { const {translate} = useLocalize(); const label = translate(FILTER_VIEW_MAP[baseFilterKey].labelKey); @@ -128,6 +131,7 @@ function FilterPopup({baseFilterKey, searchAdvancedFiltersForm, closeOverlay, se return ( ({ + (filterKey, isDefault): FilterItem => ({ PopoverComponent: ({closeOverlay, setPopoverWidth}) => ( ), sentryLabel: getFilterSentryLabel(filterKey), - onClosePress: () => { - if (isAmountFilterKey(filterKey)) { - const equalToKey = `${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.EQUAL_TO}`; - const greaterThanKey = `${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN}`; - const lessThanKey = `${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN}`; - updateFilterQueryParams({[equalToKey]: undefined, [greaterThanKey]: undefined, [lessThanKey]: undefined}); - return; - } - - if (isDateFilterKey(filterKey)) { - const onKey = `${filterKey}${CONST.SEARCH.DATE_MODIFIERS.ON}`; - const beforeKey = `${filterKey}${CONST.SEARCH.DATE_MODIFIERS.BEFORE}`; - const afterKey = `${filterKey}${CONST.SEARCH.DATE_MODIFIERS.AFTER}`; - const rangeKey = `${filterKey}${CONST.SEARCH.DATE_MODIFIERS.RANGE}`; - updateFilterQueryParams({[onKey]: undefined, [beforeKey]: undefined, [afterKey]: undefined, [rangeKey]: undefined}); - return; - } - - if (filterKey === CONST.SEARCH.REPORT_FIELD.GLOBAL_PREFIX) { - const formValues = Object.keys(searchAdvancedFiltersForm).reduce((acc, curr) => { - if (isReportFieldKey(curr)) { - acc[curr] = undefined; - } - return acc; - }, {} as Partial); - updateFilterQueryParams(formValues); - return; - } - - updateFilterQueryParams({[filterKey]: undefined}); - }, + onClosePress: isDefault + ? undefined + : () => { + if (isAmountFilterKey(filterKey)) { + const equalToKey = `${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.EQUAL_TO}`; + const greaterThanKey = `${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN}`; + const lessThanKey = `${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN}`; + updateFilterQueryParams({[equalToKey]: undefined, [greaterThanKey]: undefined, [lessThanKey]: undefined}); + return; + } + + if (isDateFilterKey(filterKey)) { + const onKey = `${filterKey}${CONST.SEARCH.DATE_MODIFIERS.ON}`; + const beforeKey = `${filterKey}${CONST.SEARCH.DATE_MODIFIERS.BEFORE}`; + const afterKey = `${filterKey}${CONST.SEARCH.DATE_MODIFIERS.AFTER}`; + const rangeKey = `${filterKey}${CONST.SEARCH.DATE_MODIFIERS.RANGE}`; + updateFilterQueryParams({[onKey]: undefined, [beforeKey]: undefined, [afterKey]: undefined, [rangeKey]: undefined}); + return; + } + + if (filterKey === CONST.SEARCH.REPORT_FIELD.GLOBAL_PREFIX) { + const formValues = Object.keys(searchAdvancedFiltersForm).reduce((acc, curr) => { + if (isReportFieldKey(curr)) { + acc[curr] = undefined; + } + return acc; + }, {} as Partial); + updateFilterQueryParams(formValues); + return; + } + + updateFilterQueryParams({[filterKey]: undefined}); + }, }), ); - const clearFilters = () => { - setFilterQueryParams(getAdvancedFiltersToReset(searchAdvancedFiltersForm ?? {})); + const resetFilters = () => { + if (!currentSearchQueryJSON) { + return; + } + + Navigation.setParams({q: buildQueryStringWithResetFilters(currentSearchQueryJSON, currentDefaultSearchQueryJSON), rawQuery: undefined}); setSearchContext(false); }; const isCategoryFilterLoading = shouldShowInitialCategoryFilterLoading(queryJSON, areCategoriesLoaded, isLoadingCategories, isOffline); @@ -209,7 +222,9 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON): UseSearchFiltersBarRes filters, hasErrors: Object.keys(currentSearchResults?.errors ?? {}).length > 0 && !isOffline, shouldShowFiltersBarLoading: shouldShowFiltersBarLoading || isCategoryFilterLoading, - clearFilters, + shouldShowResetFilters: + currentDefaultSearchQueryJSON && currentSearchQueryJSON ? hasFiltersChangedFromDefault(currentSearchQueryJSON, currentDefaultSearchQueryJSON) : filters.length > 0, + resetFilters, }; } diff --git a/src/components/Search/SearchQueryProvider.tsx b/src/components/Search/SearchQueryProvider.tsx index 48c792a4b7b5..c0fdf8096e9c 100644 --- a/src/components/Search/SearchQueryProvider.tsx +++ b/src/components/Search/SearchQueryProvider.tsx @@ -1,15 +1,19 @@ import useCardFeedsForDisplay from '@hooks/useCardFeedsForDisplay'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLoadSearchCategoryData from '@hooks/useLoadSearchCategoryData'; +import useOnyx from '@hooks/useOnyx'; import usePreviousDefined from '@hooks/usePreviousDefined'; import useRootNavigationState from '@hooks/useRootNavigationState'; import {getDeepestFocusedScreen} from '@libs/Navigation/Navigation'; -import {buildSearchQueryJSON, buildSearchQueryString} from '@libs/SearchQueryUtils'; -import {getSuggestedSearches} from '@libs/SearchUIUtils'; +import {buildSearchQueryJSON, buildSearchQueryString, doesQueryMatchDefaultFilterKeysAndType} from '@libs/SearchQueryUtils'; +import type {SearchKey} from '@libs/SearchUIUtils'; +import {getLastSearchQuery, getSuggestedSearches, savedSearchIDToSearchKey, getSuggestedSearchesVisibility} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; +import {defaultExpensifyCardSelector} from '@src/selectors/Card'; import type {NavigationState} from '@react-navigation/routers'; @@ -24,6 +28,11 @@ type SearchQueryProviderProps = { children: React.ReactNode; }; +const typeToGenericKey: Record = { + [CONST.SEARCH.DATA_TYPES.EXPENSE]: CONST.SEARCH.SEARCH_KEYS.EXPENSES, + [CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT]: CONST.SEARCH.SEARCH_KEYS.REPORTS, +}; + function selectSearchQueryParam(state: NavigationState | undefined) { const focused = getDeepestFocusedScreen(state); return focused?.name === SCREENS.SEARCH.ROOT ? (focused.params?.q as string | undefined) : undefined; @@ -46,27 +55,107 @@ function SearchQueryProvider({children}: SearchQueryProviderProps) { useLoadSearchCategoryData({shouldLoad: shouldLoadCategoryData}); const {defaultCardFeed, activeExpensifyCardFeedID} = useCardFeedsForDisplay(); - const {accountID} = useCurrentUserPersonalDetails(); + const [defaultExpensifyCardID] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST, {selector: (card) => defaultExpensifyCardSelector(card)?.id}); + const {accountID, email} = useCurrentUserPersonalDetails(); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const defaultCardFeedID = defaultCardFeed?.id; - const suggestedSearches = getSuggestedSearches(accountID, defaultCardFeedID, undefined, activeExpensifyCardFeedID); + const {shouldShowExpensifyCard} = getSuggestedSearchesVisibility(email, {}, policies, undefined); + const suggestedSearches = getSuggestedSearches(accountID, defaultCardFeedID ?? defaultExpensifyCardID, shouldShowExpensifyCard, activeExpensifyCardFeedID); const currentSearchHash = currentSearchQueryJSON?.hash ?? -1; const currentSimilarSearchHash = currentSearchQueryJSON?.similarSearchHash ?? -1; - const currentSearchKey = Object.values(suggestedSearches).find((search) => search.similarSearchHash === currentSimilarSearchHash)?.key; + const [prevCurrentSearchHash, setPrevCurrentSearchHash] = useState(currentSearchHash); + + const [searchFilters] = useOnyx(ONYXKEYS.SEARCH_FILTERS); + const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); const [shouldResetSearchQuery, setShouldResetSearchQuery] = useState(false); + const getSearchKeyForQuery = (queryJSON = currentSearchQueryJSON) => { + const suggestedSearchKey = Object.values(suggestedSearches).find((search) => { + const lastSearchFilterQuery = getLastSearchQuery(searchFilters, search.key); + const lastSearchFilter = lastSearchFilterQuery ? buildSearchQueryJSON(lastSearchFilterQuery) : undefined; + return search.similarSearchHash === queryJSON?.similarSearchHash || lastSearchFilter?.similarSearchHash === queryJSON?.similarSearchHash; + })?.key; + if (suggestedSearchKey) { + return suggestedSearchKey; + } + + const savedSearchID = Object.keys(savedSearches ?? {}).find((id) => { + const savedSearchQuery = savedSearches?.[id].query; + const lastSavedSearchQuery = getLastSearchQuery(searchFilters, savedSearchIDToSearchKey(id)); + + return ( + (savedSearchQuery ? buildSearchQueryJSON(savedSearchQuery)?.hash === queryJSON?.hash : false) || + (lastSavedSearchQuery ? buildSearchQueryJSON(lastSavedSearchQuery)?.hash === queryJSON?.hash : false) + ); + }); + + if (savedSearchID) { + return savedSearchIDToSearchKey(savedSearchID); + } + + return queryJSON?.type ? typeToGenericKey[queryJSON.type] : undefined; + }; + + const [currentSearchKey, setCurrentSearchKey] = useState(getSearchKeyForQuery); + // Search key can be undefined when the query is not bound to any search key (e.g., query with type of chat). + // `null` means there is no pending current search key. + const [pendingCurrentSearchKey, setPendingCurrentSearchKey] = useState(null); + + const currentDefaultSearchQueryString = currentSearchKey ? suggestedSearches[currentSearchKey]?.searchQuery : undefined; + const currentDefaultSearchQueryJSON = currentDefaultSearchQueryString ? buildSearchQueryJSON(currentDefaultSearchQueryString) : undefined; + const currentDefaultSearchQueryFilterKeys = new Set(currentDefaultSearchQueryJSON?.flatFilters.map((filter) => filter.key)); + + const resetSearchKey = (queryJSON = currentSearchQueryJSON) => { + const searchKey = getSearchKeyForQuery(queryJSON); + if (queryJSON?.hash !== currentSearchHash) { + setPendingCurrentSearchKey(searchKey); + } else { + setCurrentSearchKey(searchKey); + } + }; + + if (currentSearchHash !== prevCurrentSearchHash) { + setPrevCurrentSearchHash(currentSearchHash); + + if (pendingCurrentSearchKey !== null) { + setCurrentSearchKey(pendingCurrentSearchKey); + setPendingCurrentSearchKey(null); + } + // Every time the query changes, we invalidate the currentSearchKey if the new query doesn't have the default filters + // from the currently selected search key query or the type is different. For example, the "Card statements" suggested + // search default filters are Feed and Posted. When the query changes (by removing Posted), the search key becomes invalid, + // it's not a "Card statements" search anymore. This can happen when accessing the page through a link/deeplink. + else if (!doesQueryMatchDefaultFilterKeysAndType(currentSearchQueryJSON, currentDefaultSearchQueryJSON)) { + resetSearchKey(); + } + } + const queryValue: SearchQueryContextValue = { currentSearchHash, currentSimilarSearchHash, currentSearchKey, currentSearchQueryJSON, + currentDefaultSearchQueryJSON, + currentDefaultSearchQueryFilterKeys, suggestedSearches, shouldResetSearchQuery, }; const queryActionsValue: SearchQueryActionsValue = { setShouldResetSearchQuery, + setCurrentSearchKey: (key, pendingQuery) => { + // We pending the update of the currentSearchKey to be updated later at the same time with the + // currentSearchQueryJSON so the consumer won't see mismatch value between search key and query JSON. + const pending = pendingQuery !== undefined && buildSearchQueryJSON(pendingQuery)?.hash !== currentSearchHash; + if (pending) { + setPendingCurrentSearchKey(key); + } else { + setCurrentSearchKey(key); + } + }, + resetSearchKey, }; return ( diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 7c31ae6b4f50..ebff00684f71 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -36,7 +36,7 @@ import {getReportAction} from '@libs/ReportActionsUtils'; import {isHiddenForCurrentUser, isOneOnOneChat} from '@libs/ReportUtils'; import type {OptionData} from '@libs/ReportUtils'; import {getAutocompleteQueryWithComma, getTrimmedUserSearchQueryPreservingComma} from '@libs/SearchAutocompleteUtils'; -import {buildUserReadableQueryString, getQueryWithUpdatedValues, sanitizeSearchValue} from '@libs/SearchQueryUtils'; +import {buildSearchQueryJSON, buildUserReadableQueryString, getQueryWithUpdatedValues, sanitizeSearchValue} from '@libs/SearchQueryUtils'; import StringUtils from '@libs/StringUtils'; import Navigation from '@navigation/Navigation'; @@ -83,7 +83,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla const {translate, formatPhoneNumber, dateFnsLocale} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); const styles = useThemeStyles(); - const {setShouldResetSearchQuery} = useSearchQueryActions(); + const {setShouldResetSearchQuery, resetSearchKey} = useSearchQueryActions(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountID = currentUserPersonalDetails.accountID; const [isSearchingForReports] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS); @@ -102,7 +102,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice); const {query: pendingInitialQuery, isFromSearchPageSearchButton} = peekPendingRouterState(); - const {currentSearchQueryJSON} = useSearchQueryContext(); + const {currentSearchQueryJSON, currentSearchHash} = useSearchQueryContext(); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [personalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST); @@ -375,6 +375,10 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla backHistory(() => { onRouterClose(); setSearchContext(true); + const updatedQueryJSON = buildSearchQueryJSON(updatedQuery); + if (currentSearchHash !== updatedQueryJSON?.hash) { + resetSearchKey(updatedQueryJSON); + } Navigation.navigate( ROUTES.SEARCH_ROOT.getRoute({query: updatedQuery, rawQuery: shouldSkipAmountConversion || !isFromSearchPageSearchButton ? undefined : queryWithSubstitutions}), ); @@ -383,7 +387,18 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla setTextInputValue(''); setAutocompleteQueryValue(''); }, - [autocompleteSubstitutions, currentUserAccountID, onRouterClose, setAutocompleteQueryValue, setTextInputValue, setShouldResetSearchQuery, isFromSearchPageSearchButton, policies], + [ + autocompleteSubstitutions, + currentUserAccountID, + currentSearchHash, + onRouterClose, + setAutocompleteQueryValue, + setTextInputValue, + setShouldResetSearchQuery, + resetSearchKey, + isFromSearchPageSearchButton, + policies, + ], ); const onListItemPress = useCallback( diff --git a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx index e5fbc01e37f9..b128f7d02369 100644 --- a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx +++ b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx @@ -3,7 +3,7 @@ */ import WorkspaceAvatar from '@components/Avatar/WorkspaceAvatar'; import getSearchTabRoute from '@components/Navigation/NavigationTabBar/getSearchTabRoute'; -import {useSearchSelectionActions} from '@components/Search/SearchContext'; +import {useSearchQueryActions, useSearchSelectionActions} from '@components/Search/SearchContext'; import type {SearchQueryItem} from '@components/Search/SearchList/ListItem/SearchQueryListItem'; import TextWithIconCell from '@components/Search/SearchList/ListItem/TextWithIconCell'; import TextWithTooltip from '@components/TextWithTooltip'; @@ -24,8 +24,8 @@ import navigateToWorkspaceSettingsRoute from '@libs/Navigation/helpers/navigateT import Navigation from '@libs/Navigation/Navigation'; import {shouldShowPolicy} from '@libs/PolicyUtils'; import navigateToCannedSpendSearch from '@libs/SearchNavigationUtils'; -import {SEARCH_TYPE_MENU_ICON_NAMES} from '@libs/SearchUIUtils'; -import type {SearchTypeMenuItem, SearchTypeMenuSection} from '@libs/SearchUIUtils'; +import {getLastSearchQuery, SEARCH_TYPE_MENU_ICON_NAMES} from '@libs/SearchUIUtils'; +import type {SearchKey, SearchTypeMenuItem, SearchTypeMenuSection} from '@libs/SearchUIUtils'; import navigationRef from '@navigation/navigationRef'; @@ -44,6 +44,7 @@ import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import {isAdminSelector} from '@src/selectors/Domain'; +import {lastExpensesSearchQuerySelector} from '@src/selectors/SearchFilters'; import {emailSelector} from '@src/selectors/Session'; import type * as OnyxTypes from '@src/types/onyx'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -112,7 +113,7 @@ type BuildSpendNavigationItemsParams = { rightElement: ReactNode; getItemText: (item: SearchTypeMenuItem) => string; getDestinationText: (destination: string) => string; - onSelect: (searchQuery: string) => void; + onSelect: (searchKey: SearchKey, searchQuery: string) => void; }; type BuildWorkspaceNavigationItemsParams = { @@ -254,7 +255,7 @@ function buildSpendNavigationItems({sections, icons, rightElement, getItemText, return { text: getDestinationText(itemText), singleIcon: icons[item.icon], - action: () => onSelect(item.searchQuery), + action: () => onSelect(item.key, item.searchQuery), keyForList: `spend_${item.key}`, rightElement, matchTerms: [itemText], @@ -370,13 +371,15 @@ function useNavigationSuggestions(query: string, shouldWatchForApprovals = true) const icons = useMemoizedLazyExpensifyIcons(SEARCH_ROUTER_ICON_NAMES); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const [lastSearchParams] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY); + const [searchFilters] = useOnyx(ONYXKEYS.SEARCH_FILTERS); const [allDomains] = useOnyx(ONYXKEYS.COLLECTION.DOMAIN); const createItems = useCreateNavigationSuggestions(query); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); const {clearSelectedTransactions} = useSearchSelectionActions(); - const {typeMenuSections} = useSearchTypeMenuSections(undefined, shouldWatchForApprovals); + const typeMenuSections = useSearchTypeMenuSections(shouldWatchForApprovals); + const {setCurrentSearchKey} = useSearchQueryActions(); const {accountMenuItemsData, generalMenuItemsData} = useSettingsNavigationMenuData(); const topLevelItems = buildTopLevelNavigationItems({ @@ -389,7 +392,7 @@ function useNavigationSuggestions(query: string, shouldWatchForApprovals = true) account: translate('initialSettingsPage.account'), }, icons, - getSpendRoute: () => getSearchTabRoute(navigationRef.getRootState(), lastSearchParams), + getSpendRoute: () => getSearchTabRoute(navigationRef.getRootState(), lastSearchParams, lastExpensesSearchQuerySelector(searchFilters)), getDestinationText: (destination) => getGoToText(translate, destination), }); @@ -407,7 +410,8 @@ function useNavigationSuggestions(query: string, shouldWatchForApprovals = true) ), getItemText: (item) => translate(item.translationPath), getDestinationText: (destination) => getGoToText(translate, destination), - onSelect: (searchQuery) => navigateToCannedSpendSearch(searchQuery, clearSelectedTransactions), + onSelect: (searchKey, searchQuery) => + navigateToCannedSpendSearch(searchKey, searchQuery, getLastSearchQuery(searchFilters, searchKey), clearSelectedTransactions, setCurrentSearchKey), }); const workspaceItems = buildWorkspaceNavigationItems({ diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index 0daa952154c3..183c01ee6766 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -105,7 +105,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { const {selectedTransactions, excludedTransactions = getEmptyObject(), areAllMatchingItemsSelected, selectedReports} = useSearchSelectionContext(); const {currentSearchResults} = useSearchResultsContext(); const {currentSearchHash, currentSearchKey, currentSearchQueryJSON} = useSearchQueryContext(); - const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true, areAllMatchingItemsSelected); + const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, true, areAllMatchingItemsSelected); const {isOffline} = useNetwork(); const activePolicy = useActivePolicy(); // The server converts search figures to the active policy's currency when the query carries no explicit target. diff --git a/src/components/Search/hooks/useUpdateFilterQuery.tsx b/src/components/Search/hooks/useUpdateFilterQuery.tsx index 1a1e171dc90d..80e444b76e06 100644 --- a/src/components/Search/hooks/useUpdateFilterQuery.tsx +++ b/src/components/Search/hooks/useUpdateFilterQuery.tsx @@ -1,10 +1,11 @@ +import {useSearchQueryActions, useSearchQueryContext} from '@components/Search/SearchContext'; import type {SearchQueryJSON} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import Navigation from '@libs/Navigation/Navigation'; -import {buildFilterQueryWithSortDefaults} from '@libs/SearchQueryUtils'; +import {buildFilterQueryWithSortDefaults, buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import {filterValidHasValues} from '@libs/SearchUIUtils'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -13,6 +14,8 @@ import {getEmptyObject} from '@src/types/utils/EmptyObject'; function useUpdateFilterQuery(queryJSON: SearchQueryJSON | undefined) { const {translate} = useLocalize(); + const {resetSearchKey} = useSearchQueryActions(); + const {currentSearchHash} = useSearchQueryContext(); const [searchAdvancedFiltersForm = getEmptyObject>()] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); @@ -49,6 +52,13 @@ function useUpdateFilterQuery(queryJSON: SearchQueryJSON | undefined) { return; } + if (values.type && searchAdvancedFiltersForm.type !== values.type) { + const newQueryJSON = buildSearchQueryJSON(queryString); + if (currentSearchHash !== newQueryJSON?.hash) { + resetSearchKey(newQueryJSON); + } + } + Navigation.setParams({q: queryString, rawQuery: undefined}); } diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 5ca81d480796..de41349f5c19 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -50,6 +50,7 @@ import { isTransactionListItemType, isTransactionReportGroupListItemType, isTransactionSearchType, + searchKeyToSavedSearchID, shouldShowEmptyState, shouldShowYear as shouldShowYearUtil, } from '@libs/SearchUIUtils'; @@ -189,7 +190,7 @@ function Search({ const searchDataType = useMemo(() => (shouldUseLiveData ? CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT : searchResults?.search?.type), [shouldUseLiveData, searchResults?.search?.type]); const isExpenseAllMatchingSelection = type === CONST.SEARCH.DATA_TYPES.EXPENSE && areAllMatchingItemsSelected; const isAllMatchingItemsCountMissing = isExpenseAllMatchingSelection && typeof searchResults?.search?.count !== 'number'; - const shouldCalculateExpenseTotals = useSearchShouldCalculateTotals(currentSearchKey, hash, offset === 0 || isAllMatchingItemsCountMissing, isExpenseAllMatchingSelection); + const shouldCalculateExpenseTotals = useSearchShouldCalculateTotals(currentSearchKey, offset === 0 || isAllMatchingItemsCountMissing, isExpenseAllMatchingSelection); const shouldCalculateTotals = (areAllMatchingItemsSelected && !isExpenseAllMatchingSelection) || shouldCalculateExpenseTotals; const previousShouldCalculateTotals = usePrevious(shouldCalculateTotals); const searchRequestOffset = getSearchRequestOffsetForMissingAllMatchingCount(offset, searchResults?.search?.offset, isAllMatchingItemsCountMissing); @@ -212,14 +213,20 @@ function Search({ // Retrying a failed page always resets pagination to the first page, so totals eligibility // must be evaluated as if we're on the first page rather than the (possibly paginated) offset. - const shouldCalculateTotalsOnRetry = useSearchShouldCalculateTotals(currentSearchKey, hash, true, areAllMatchingItemsSelected); + const shouldCalculateTotalsOnRetry = useSearchShouldCalculateTotals(currentSearchKey, true, areAllMatchingItemsSelected); const previousReportActions = usePrevious(reportActions); const {translate} = useLocalize(); const {getCurrencyDecimals} = useCurrencyListActions(); const searchListRef = useRef | null>(null); - const savedSearchSelector = useCallback((searches: OnyxEntry) => searches?.[hash], [hash]); + const savedSearchSelector = useCallback( + (searches: OnyxEntry) => { + const savedSearchID = searchKeyToSavedSearchID(currentSearchKey); + return savedSearchID ? searches?.[savedSearchID] : undefined; + }, + [currentSearchKey], + ); const [savedSearch] = useOnyx(ONYXKEYS.SAVED_SEARCHES, { selector: savedSearchSelector, }); @@ -469,7 +476,7 @@ function Search({ // We don't need to run the effect on change of isFocused. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [handleSearch, hasErrors, isOffline, offset, queryJSON, currentSearchKey, shouldCalculateTotals, validGroupBy, searchRequestOffset]); + }, [handleSearch, hasErrors, isOffline, offset, queryJSON, shouldCalculateTotals, validGroupBy, searchRequestOffset]); useEffect(() => { if (!shouldRetrySearchWithTotalsOrGroupedRef.current || searchResults?.search?.isLoading || (!shouldCalculateTotals && !validGroupBy)) { diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 0ad23ce0f847..85645a486fca 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -189,12 +189,16 @@ type SearchQueryContextValue = { currentSimilarSearchHash: number; currentSearchKey: SearchKey | undefined; currentSearchQueryJSON: Readonly | undefined; + currentDefaultSearchQueryJSON: SearchQueryJSON | undefined; + currentDefaultSearchQueryFilterKeys: Set; suggestedSearches: Record; shouldResetSearchQuery: boolean; }; type SearchQueryActionsValue = { setShouldResetSearchQuery: (shouldReset: boolean) => void; + setCurrentSearchKey: (searchKey: SearchKey, pendingQuery?: string) => void; + resetSearchKey: (queryJSON: SearchQueryJSON | undefined) => void; }; type SearchResultsContextValue = { @@ -346,23 +350,23 @@ type SearchAmountFilterKeys = | typeof CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT_REIMBURSED; type SearchAmountValues = Record, string | undefined>; -type SearchFilterKey = - | SyntaxFilterKey - | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE - | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY - | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW - | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.COLUMNS - | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT - | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW; - type UserFriendlyKey = ValueOf; type UserFriendlyValue = ValueOf; +type QueryFilterKey = SyntaxFilterKey | ReportFieldTextKey; type QueryFilters = Array<{ - key: SearchFilterKey; + key: QueryFilterKey; filters: QueryFilter[]; }>; +type SearchFilterKey = + | QueryFilterKey + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.COLUMNS + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT; + type RawFilterKey = SyntaxFilterKey | ValueOf; type RawQueryFilter = { @@ -507,6 +511,7 @@ export type { QueryFilter, Filter, QueryFilters, + QueryFilterKey, SyntaxFilterKey, RawQueryFilter, SearchFilterKey, diff --git a/src/components/Table/TableFilterBar/index.tsx b/src/components/Table/TableFilterBar/index.tsx index a69a169fff80..3c50ca27c1d4 100644 --- a/src/components/Table/TableFilterBar/index.tsx +++ b/src/components/Table/TableFilterBar/index.tsx @@ -1,5 +1,5 @@ import DropdownButton from '@components/Search/FilterDropdowns/DropdownButton'; -import SearchFiltersClearButton from '@components/Search/SearchPageHeader/SearchFiltersClearButton'; +import SearchFiltersResetButton from '@components/Search/SearchPageHeader/SearchFiltersResetButton'; import {useTableContext} from '@components/Table/TableContext'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -18,11 +18,11 @@ type TableFilterBarProps = PropsWithChildren<{ /** Label and accessibility label for the search input. */ label: string; - /** Whether to show a "Clear" button that resets all active filters. */ - shouldShowClearFiltersButton?: boolean; + /** Whether to show a "Reset" button that resets all active filters. */ + shouldShowResetFiltersButton?: boolean; }>; -export default function TableFilterBar({label, shouldShowClearFiltersButton, children}: TableFilterBarProps) { +export default function TableFilterBar({label, shouldShowResetFiltersButton, children}: TableFilterBarProps) { const styles = useThemeStyles(); const {filterConfig, tableMethods, activeFilters, onSearchStringChange, columns, narrowLayoutSortColumn, originalDataLength, shouldUseNarrowTableLayout} = useTableContext(); @@ -48,11 +48,11 @@ export default function TableFilterBar({label, shouldShowClearFiltersButton, chi }; }); - const clearAllFilters = () => { + const resetFilters = () => { for (const filter of appliedFilters) { tableMethods.updateFilter({key: filter.key, value: []}); } - // Also clear the search input so the Clear button resets both the filters and the search text. + // Also clear the search input so the Reset button resets both the filters and the search text. tableMethods.updateSearchString(''); onSearchStringChange?.(''); }; @@ -69,7 +69,7 @@ export default function TableFilterBar({label, shouldShowClearFiltersButton, chi onClosePress={filter.onClosePress} /> ))} - {!!shouldShowClearFiltersButton && } + {!!shouldShowResetFiltersButton && } ); diff --git a/src/components/Tables/WorkspaceListTable/index.tsx b/src/components/Tables/WorkspaceListTable/index.tsx index a726e7176960..504682a3e635 100644 --- a/src/components/Tables/WorkspaceListTable/index.tsx +++ b/src/components/Tables/WorkspaceListTable/index.tsx @@ -127,7 +127,7 @@ export default function WorkspaceListTable({ref, workspaces, headerComponent, on const searchBarComponent = ( ); const tableHeaderComponent = composeTableListHeader(headerComponent, searchBarComponent); diff --git a/src/hooks/useActiveSavedSearch.ts b/src/hooks/useActiveSavedSearch.ts new file mode 100644 index 000000000000..48b315fdc877 --- /dev/null +++ b/src/hooks/useActiveSavedSearch.ts @@ -0,0 +1,27 @@ +import {useSearchQueryContext} from '@components/Search/SearchContext'; + +import {searchKeyToSavedSearchID} from '@libs/SearchUIUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; + +function useActiveSavedSearch() { + const {isOffline} = useNetwork(); + const {currentSearchKey} = useSearchQueryContext(); + const [activeSavedSearch] = useOnyx(ONYXKEYS.SAVED_SEARCHES, { + selector: (savedSearches) => { + const activeSavedSearchID = searchKeyToSavedSearchID(currentSearchKey); + const item = activeSavedSearchID ? savedSearches?.[activeSavedSearchID] : undefined; + if (!item || (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !isOffline)) { + return undefined; + } + return item; + }, + }); + return activeSavedSearch; +} + +export default useActiveSavedSearch; diff --git a/src/hooks/useAutocompleteSuggestions.ts b/src/hooks/useAutocompleteSuggestions.ts index d5cc8be676c1..ab30a8770967 100644 --- a/src/hooks/useAutocompleteSuggestions.ts +++ b/src/hooks/useAutocompleteSuggestions.ts @@ -11,6 +11,7 @@ import type {OptionList} from '@libs/OptionsListUtils'; import {getSearchOptions} from '@libs/OptionsListUtils'; import {getAllTaxRates, getCleanedTagName, getExpensifyTeamExclusions, shouldShowPolicy} from '@libs/PolicyUtils'; import { + CONTINUATION_DETECTION_SEARCH_FILTER_KEYS, getAutocompleteCategories, getAutocompleteRecentCategories, getAutocompleteRecentTags, @@ -21,7 +22,7 @@ import { import {getUserFriendlyKey, getUserFriendlyValue} from '@libs/SearchQueryUtils'; import {getDatePresets, getHasOptions} from '@libs/SearchUIUtils'; -import CONST, {CONTINUATION_DETECTION_SEARCH_FILTER_KEYS} from '@src/CONST'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Beta, CardFeeds, CardList, PersonalDetailsList, Policy} from '@src/types/onyx'; import type {VisibleReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; diff --git a/src/hooks/useDeleteSavedSearch.tsx b/src/hooks/useDeleteSavedSearch.tsx index fb158e3f8df2..2cfed641c953 100644 --- a/src/hooks/useDeleteSavedSearch.tsx +++ b/src/hooks/useDeleteSavedSearch.tsx @@ -1,25 +1,31 @@ import {ModalActions} from '@components/Modal/Global/ModalContext'; -import {useSearchQueryContext} from '@components/Search/SearchContext'; +import {useSearchQueryActions, useSearchQueryContext} from '@components/Search/SearchContext'; import {deleteSavedSearch} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; import {buildCannedSearchQuery} from '@libs/SearchQueryUtils'; +import {searchKeyToSavedSearchID} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import {lastExpensesSearchQuerySelector} from '@src/selectors/SearchFilters'; import {useCallback} from 'react'; import useConfirmModal from './useConfirmModal'; import useLocalize from './useLocalize'; +import useOnyx from './useOnyx'; export default function useDeleteSavedSearch() { const {translate} = useLocalize(); - const {currentSearchHash} = useSearchQueryContext(); + const {currentSearchKey} = useSearchQueryContext(); + const {setCurrentSearchKey} = useSearchQueryActions(); const {showConfirmModal} = useConfirmModal(); + const [lastExpensesSearchQuery] = useOnyx(ONYXKEYS.SEARCH_FILTERS, {selector: lastExpensesSearchQuerySelector}); const handleDeleteSavedSearch = useCallback( - (hash: number) => { + (savedSearchID: string) => { showConfirmModal({ title: translate('search.deleteSavedSearch'), prompt: translate('search.deleteSavedSearchConfirm'), @@ -30,18 +36,16 @@ export default function useDeleteSavedSearch() { if (result.action !== ModalActions.CONFIRM) { return; } - deleteSavedSearch(hash); - - if (hash === currentSearchHash) { - Navigation.navigate( - ROUTES.SEARCH_ROOT.getRoute({ - query: buildCannedSearchQuery(), - }), - ); + deleteSavedSearch(savedSearchID); + + if (savedSearchID === searchKeyToSavedSearchID(currentSearchKey)) { + const query = lastExpensesSearchQuery ?? buildCannedSearchQuery(); + setCurrentSearchKey(CONST.SEARCH.SEARCH_KEYS.EXPENSES, query); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query})); } }); }, - [showConfirmModal, translate, currentSearchHash], + [showConfirmModal, translate, currentSearchKey, lastExpensesSearchQuery, setCurrentSearchKey], ); return {showDeleteModal: handleDeleteSavedSearch}; diff --git a/src/hooks/useLifecycleActions.tsx b/src/hooks/useLifecycleActions.tsx index 4edea5c264f7..788be01b9c1e 100644 --- a/src/hooks/useLifecycleActions.tsx +++ b/src/hooks/useLifecycleActions.tsx @@ -134,7 +134,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, const {currentSearchResults} = useSearchResultsContext(); const {selectedTransactionIDs} = useSearchSelectionContext(); const {clearSelectedTransactions} = useSearchSelectionActions(); - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Send', 'ThumbsUp', 'CircularArrowBackwards', 'Clear', 'MoneyBag', 'ArrowRight', 'DocumentCheck']); diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 40c8cb578fa1..27d1a9746fe6 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -5,7 +5,7 @@ import {ModalActions} from '@components/Modal/Global/ModalContext'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import {useOpenSearchReportSubmitToPopover} from '@components/ReportSubmitToPopoverAnchor'; import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; -import type {BulkPaySelectionData, PaymentData, SearchColumnType, SearchFilterKey, SearchQueryJSON, SelectedReports, SelectedTransactions} from '@components/Search/types'; +import type {BulkPaySelectionData, PaymentData, QueryFilterKey, SearchColumnType, SearchFilterKey, SearchQueryJSON, SelectedReports, SelectedTransactions} from '@components/Search/types'; import {getAccountingIntegrationDisplayName, getExportLabelForConnection} from '@libs/AccountingUtils'; import {getExpensifyCardStatementPDF} from '@libs/actions/CompanyCards'; @@ -224,7 +224,7 @@ function addSelectedGroupsFilter(queryJSON: SearchQueryJSON, selectedTransaction return queryJSON; } - const filterEntries: Array<{key: SearchFilterKey; value: string | number}> = []; + const filterEntries: Array<{key: QueryFilterKey; value: string | number}> = []; for (const key of groupKeys) { const group = searchData[key as keyof SearchResultDataType]; if (!group) { @@ -259,7 +259,7 @@ function getAllMatchingExportQueryAndExclusions( searchData: SearchResultDataType | undefined, ): {queryJSON: SearchQueryJSON; excludedTransactionIDList: string[]} | undefined { const excludedTransactionIDList: string[] = []; - const excludedGroupEntries: Array<{key: SearchFilterKey; value: string | number}> = []; + const excludedGroupEntries: Array<{key: QueryFilterKey; value: string | number}> = []; for (const key of Object.keys(excludedTransactions)) { if (!isGroupEntry(key)) { @@ -722,7 +722,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }); const {hash} = queryJSON ?? {}; - const shouldCalculateTotalsOnRefresh = useSearchShouldCalculateTotals(currentSearchKey, hash, true); + const shouldCalculateTotalsOnRefresh = useSearchShouldCalculateTotals(currentSearchKey, true); const isExpenseType = queryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE; const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); // Use currentSearchResults, not the lastNonEmpty fallback: the export scope must reflect the query on screen now, diff --git a/src/hooks/useSearchPageSetup.ts b/src/hooks/useSearchPageSetup.ts index d5058f86cf5a..41379d632161 100644 --- a/src/hooks/useSearchPageSetup.ts +++ b/src/hooks/useSearchPageSetup.ts @@ -38,7 +38,7 @@ function useSearchPageSetup(queryJSON: Readonly | undefined) { const hash = queryJSON?.hash; // Without this, a plain page-level fetch during active select-all clears totals on arrival // (shouldClearTotals in getOnyxLoadingData), wiping a total the Search-internal effect already fetched. - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, hash, true, areAllMatchingItemsSelected); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true, areAllMatchingItemsSelected); // Derived primitives so effects do not depend on the whole snapshot object (new reference every // Onyx merge) while exhaustive-deps still sees every transition that matters for firing search(). diff --git a/src/hooks/useSearchShouldCalculateTotals.ts b/src/hooks/useSearchShouldCalculateTotals.ts index 7b805d24bab9..19694ba94ed1 100644 --- a/src/hooks/useSearchShouldCalculateTotals.ts +++ b/src/hooks/useSearchShouldCalculateTotals.ts @@ -1,3 +1,4 @@ +import {searchKeyToSavedSearchID} from '@libs/SearchUIUtils'; import type {SearchKey} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; @@ -14,7 +15,7 @@ function getSearchRequestOffsetForMissingAllMatchingCount(offset: number, server return Math.min(offset, serverOffset ?? offset); } -function useSearchShouldCalculateTotals(searchKey: SearchKey | undefined, searchHash: number | undefined, enabled: boolean, areAllMatchingItemsSelected = false) { +function useSearchShouldCalculateTotals(searchKey: SearchKey | undefined, enabled: boolean, areAllMatchingItemsSelected = false) { const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); const shouldCalculateTotals = useMemo(() => { @@ -50,10 +51,11 @@ function useSearchShouldCalculateTotals(searchKey: SearchKey | undefined, search ]; const isSuggestedSearchWithTotals = eligibleSearchKeys.includes(searchKey); - const isSavedSearch = searchHash !== undefined && savedSearches && !!savedSearches[searchHash]; + const savedSearchID = searchKeyToSavedSearchID(searchKey); + const isSavedSearch = savedSearchID !== undefined && savedSearches && !!savedSearches[savedSearchID]; return isSuggestedSearchWithTotals || isSavedSearch; - }, [enabled, savedSearches, searchKey, searchHash, areAllMatchingItemsSelected]); + }, [enabled, savedSearches, searchKey, areAllMatchingItemsSelected]); return shouldCalculateTotals ?? false; } diff --git a/src/hooks/useSearchTypeMenuSections.ts b/src/hooks/useSearchTypeMenuSections.ts index 80ded05f6a1a..d6e4d2e81403 100644 --- a/src/hooks/useSearchTypeMenuSections.ts +++ b/src/hooks/useSearchTypeMenuSections.ts @@ -1,6 +1,5 @@ -import {createTypeMenuSections, doesSearchItemMatchSort} from '@libs/SearchUIUtils'; +import {createTypeMenuSections} from '@libs/SearchUIUtils'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {isTrackIntentUserSelector} from '@src/selectors/Onboarding'; import type {Policy, Session} from '@src/types/onyx'; @@ -49,24 +48,14 @@ const currentUserLoginAndAccountIDSelector = (session: OnyxEntry) => ({ accountID: session?.accountID, }); -type UseSearchTypeMenuSectionsParams = { - hash?: number; - similarSearchHash?: number; - sortBy?: string; - sortOrder?: string; - type?: string; -}; - /** - * Get a list of all search groupings, along with their search items. Also returns the - * currently focused search, based on the hash. + * Get a list of all search groupings, along with their search items. * * `isScreenFocused` gates the reports-awaiting-approval watch so an off-screen consumer stops recomputing it. It * defaults to `true` (always watch) for consumers rendered outside a navigator or where focus can't be tracked * reliably, so this hook never depends on a navigation context itself. */ -const useSearchTypeMenuSections = (queryParams?: UseSearchTypeMenuSectionsParams, isScreenFocused = true) => { - const {hash, similarSearchHash, sortBy, sortOrder, type} = queryParams ?? {}; +const useSearchTypeMenuSections = (isScreenFocused = true) => { const [defaultExpensifyCard] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST, {selector: defaultExpensifyCardSelector}); const {defaultCardFeed, cardFeedsByPolicy, activeExpensifyCardFeedID} = useCardFeedsForDisplay(); @@ -149,67 +138,7 @@ const useSearchTypeMenuSections = (queryParams?: UseSearchTypeMenuSectionsParams ], ); - // The saved search the current query maps to (keyed by `hash`), derived from the existing `savedSearches` - // subscription. Undefined when there is no match or when the match is pending deletion (unless offline). - const activeSavedSearch = (() => { - if (hash === undefined || !savedSearches) { - return undefined; - } - const item = savedSearches[hash]; - if (!item || (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !isOffline)) { - return undefined; - } - return item; - })(); - - const activeItemIndex = (() => { - // A saved search is not part of `typeMenuSections`, so keep suggested-search focus off it. - if (activeSavedSearch) { - return -1; - } - - let index = 0; - for (const section of typeMenuSections) { - const found = section.menuItems.findIndex((item) => { - if (item.similarSearchHash !== similarSearchHash) { - return false; - } - return doesSearchItemMatchSort(item.key, item.searchQueryJSON?.sortBy, item.searchQueryJSON?.sortOrder, sortBy, sortOrder); - }); - if (found !== -1) { - return index + found; - } - index += section.menuItems.length; - } - - // Fallback: if no exact match found, select the generic search key matching the type - const typeToGenericKey: Record = { - [CONST.SEARCH.DATA_TYPES.EXPENSE]: CONST.SEARCH.SEARCH_KEYS.EXPENSES, - [CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT]: CONST.SEARCH.SEARCH_KEYS.REPORTS, - }; - const fallbackKey = type ? typeToGenericKey[type] : undefined; - if (fallbackKey) { - let fallbackIndex = 0; - for (const section of typeMenuSections) { - const found = section.menuItems.findIndex((item) => item.key === fallbackKey); - if (found !== -1) { - return fallbackIndex + found; - } - fallbackIndex += section.menuItems.length; - } - } - - return -1; - })(); - - const activeKey = activeItemIndex < 0 ? undefined : typeMenuSections.flatMap((section) => section.menuItems).at(activeItemIndex)?.key; - - return { - typeMenuSections, - activeItemIndex, - activeKey, - activeSavedSearch, - }; + return typeMenuSections; }; export default useSearchTypeMenuSections; diff --git a/src/hooks/useSelectionModePayment.ts b/src/hooks/useSelectionModePayment.ts index 0c42b21a9f1a..6bd3ad57f385 100644 --- a/src/hooks/useSelectionModePayment.ts +++ b/src/hooks/useSelectionModePayment.ts @@ -89,7 +89,7 @@ function useSelectionModePayment({ const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); const [ownerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(moneyRequestReport?.ownerAccountID)}); diff --git a/src/hooks/useShareSavedSearch.ts b/src/hooks/useShareSavedSearch.ts index 733c5a1d1714..d954a3e32392 100644 --- a/src/hooks/useShareSavedSearch.ts +++ b/src/hooks/useShareSavedSearch.ts @@ -14,7 +14,7 @@ const MENU_CLOSE_DELAY_MS = 800; function useShareSavedSearch() { const {environmentURL} = useEnvironment(); - const [copiedHash, setCopiedHash] = useState(null); + const [copiedID, setCopiedID] = useState(null); const timeoutRef = useRef | null>(null); useEffect(() => { @@ -23,21 +23,21 @@ function useShareSavedSearch() { }; }, []); - const handleShare = (itemHash: number, itemQuery: string) => { + const handleShare = (itemID: string, itemQuery: string) => { const url = `${environmentURL}/${ROUTES.SEARCH_ROOT.getRoute({query: itemQuery})}`; Clipboard.setString(url); - setCopiedHash(itemHash); + setCopiedID(itemID); if (timeoutRef.current !== null) { clearTimeout(timeoutRef.current); } timeoutRef.current = setTimeout(() => { - setCopiedHash((prev) => (prev === itemHash ? null : prev)); + setCopiedID((prev) => (prev === itemID ? null : prev)); timeoutRef.current = null; }, SHARE_FEEDBACK_DURATION_MS); }; - return {copiedHash, handleShare}; + return {copiedID, handleShare}; } export {MENU_CLOSE_DELAY_MS}; diff --git a/src/libs/API/parameters/DeleteSavedSearch.ts b/src/libs/API/parameters/DeleteSavedSearch.ts index 23b20204bcd2..f0f4b4a02138 100644 --- a/src/libs/API/parameters/DeleteSavedSearch.ts +++ b/src/libs/API/parameters/DeleteSavedSearch.ts @@ -1,5 +1,5 @@ type DeleteSavedSearchParams = { - hash: number; + savedSearchID: string; }; export default DeleteSavedSearchParams; diff --git a/src/libs/API/parameters/SaveSearch.ts b/src/libs/API/parameters/SaveSearch.ts index 9dd3416320c7..ef3c12972b09 100644 --- a/src/libs/API/parameters/SaveSearch.ts +++ b/src/libs/API/parameters/SaveSearch.ts @@ -2,6 +2,7 @@ import type {SearchQueryString} from '@components/Search/types'; type SaveSearchParams = { jsonQuery: SearchQueryString; + savedSearchID: string; newName?: string; }; diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index c65f1099c1bc..c968feddc52b 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -498,6 +498,7 @@ const onyxKeysToMaskFragileData = new Set([ ONYXKEYS.SAVED_SEARCHES, ONYXKEYS.SCHEDULE_CALL_DRAFT, ONYXKEYS.SCREEN_SHARE_REQUEST, + ONYXKEYS.SEARCH_FILTERS, ONYXKEYS.SEARCH_FOOTER_CONVERSION, ONYXKEYS.SEARCH_QUERY_BY_HASH, ONYXKEYS.SHARE_BANK_ACCOUNT, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 1ee960d0c550..6f3a8f02e40b 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2,7 +2,6 @@ import type {MultifactorAuthenticationPromptType} from '@components/MultifactorA import type {SearchQueryString} from '@components/Search/types'; import type {ReplacementReason} from '@libs/actions/Card'; -import type {SaveSearchParams} from '@libs/API/parameters'; import type {ReimbursementAccountStepToOpen} from '@libs/ReimbursementAccountUtils'; import type {AvatarSource} from '@libs/UserAvatarUtils'; @@ -3400,7 +3399,9 @@ type SearchAdvancedFiltersParamList = { }; type SearchSavedSearchParamList = { - [SCREENS.SEARCH.SAVED_SEARCH_RENAME_RHP]: SaveSearchParams; + [SCREENS.SEARCH.SAVED_SEARCH_RENAME_RHP]: { + id: string; + }; }; type SearchColumnsParamList = { diff --git a/src/libs/SearchAutocompleteUtils.ts b/src/libs/SearchAutocompleteUtils.ts index 394b22e35bfe..049fe93d181b 100644 --- a/src/libs/SearchAutocompleteUtils.ts +++ b/src/libs/SearchAutocompleteUtils.ts @@ -1,7 +1,7 @@ import type {SubstitutionMap} from '@components/Search/SearchRouter/getQueryWithSubstitutions'; -import type {SearchAutocompleteQueryRange, SearchAutocompleteResult, SearchColumnType} from '@components/Search/types'; +import type {SearchAutocompleteQueryRange, SearchAutocompleteResult, SearchColumnType, SearchFilterKey} from '@components/Search/types'; -import CONST, {CONTINUATION_DETECTION_SEARCH_FILTER_KEYS} from '@src/CONST'; +import CONST from '@src/CONST'; import type {PolicyCategories, PolicyTagLists, RecentlyUsedCategories, RecentlyUsedTags} from '@src/types/onyx'; import type {MarkdownRange} from '@expensify/react-native-live-markdown'; @@ -12,6 +12,16 @@ import {getTagNamesFromTagsLists} from './PolicyUtils'; import {parse} from './SearchParser/autocompleteParser'; import {getUserFriendlyKey, getUserFriendlyValue} from './SearchQueryUtils'; +const CONTINUATION_DETECTION_SEARCH_FILTER_KEYS: SearchFilterKey[] = [ + CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, + CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, + CONST.SEARCH.SYNTAX_FILTER_KEYS.ASSIGNEE, + CONST.SEARCH.SYNTAX_FILTER_KEYS.PAYER, + CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_BY, + CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTER, + CONST.SEARCH.SYNTAX_FILTER_KEYS.ATTENDEE, +]; + /** * Parses given query using the autocomplete parser. * This is a smaller and simpler version of search parser used for autocomplete displaying logic. @@ -344,6 +354,7 @@ function getTrimmedUserSearchQueryPreservingComma(textInputValue: string, fieldK } export { + CONTINUATION_DETECTION_SEARCH_FILTER_KEYS, getAutocompleteCategories, getAutocompleteQueryWithComma, getAutocompleteRecentCategories, diff --git a/src/libs/SearchNavigationUtils.ts b/src/libs/SearchNavigationUtils.ts index 925faf6c3590..67afc9ffb88e 100644 --- a/src/libs/SearchNavigationUtils.ts +++ b/src/libs/SearchNavigationUtils.ts @@ -1,12 +1,23 @@ import ROUTES from '@src/ROUTES'; +import type {SearchKey} from './SearchUIUtils'; + import {setSearchContext} from './actions/Search'; import Navigation from './Navigation/Navigation'; +import {getValidLastQuery} from './SearchQueryUtils'; -function navigateToCannedSpendSearch(searchQuery: string, clearSelectedTransactions: () => void) { +function navigateToCannedSpendSearch( + searchKey: SearchKey, + searchQuery: string, + lastSearchQuery: string | undefined, + clearSelectedTransactions: () => void, + setCurrentSearchKey: (key: SearchKey, pendingQuery?: string) => void, +) { clearSelectedTransactions(); setSearchContext(false); - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: searchQuery})); + const query = getValidLastQuery(lastSearchQuery, searchQuery); + setCurrentSearchKey(searchKey, query); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query})); } export default navigateToCannedSpendSearch; diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 2861fb35b26a..c9fe9d89562f 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -586,6 +586,30 @@ function wasViewExplicitlySet(queryJSON?: SearchQueryJSON | Readonly) { + let orderedQuery = ''; + const flatFilters = query.flatFilters + .map((filter) => { + const filterKey = filter.key; + const filters = cloneDeep(filter.filters); + filters.sort((a, b) => customCollator.compare(a.value.toString(), b.value.toString())); + return {filterString: buildFilterValuesString(filterKey, filters), filterKey}; + }) + .sort((a, b) => customCollator.compare(a.filterString, b.filterString)); + + for (const {filterString, filterKey} of flatFilters) { + if (exclude.has(filterKey)) { + continue; + } + + orderedQuery += ` ${filterString}`; + } + + const primaryHash = hashText(orderedQuery, 2 ** 32); + + return primaryHash; +} + /** * @private * Computes and returns a numerical hash for a given queryJSON. @@ -831,6 +855,23 @@ function buildSearchQueryString(queryJSON?: SearchQueryJSON | Readonly([CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD, CONST.SEARCH.SYNTAX_FILTER_KEYS.GROUP_CURRENCY]); + +function buildQueryStringWithResetFilters(currentQueryJSON: SearchQueryJSON, defaultQueryJSON: SearchQueryJSON | undefined) { + const resetFilters = (defaultQueryJSON?.flatFilters ?? []).filter((filter) => !NON_FILTER_CHIP_KEYS.has(filter.key)); + const keptFilters = currentQueryJSON.flatFilters.filter((filter) => NON_FILTER_CHIP_KEYS.has(filter.key)); + + return buildSearchQueryString({ + ...currentQueryJSON, + type: defaultQueryJSON?.type ?? currentQueryJSON.type, + flatFilters: [...resetFilters, ...keptFilters], + }); +} + +function hasFiltersChangedFromDefault(currentQueryJSON: SearchQueryJSON, defaultQueryJSON: SearchQueryJSON) { + return getQueryHashWithoutFilters(currentQueryJSON, NON_FILTER_CHIP_KEYS) !== getQueryHashWithoutFilters(defaultQueryJSON, NON_FILTER_CHIP_KEYS); +} + function getSanitizedRawFilters(queryJSON: SearchQueryJSON): RawQueryFilter[] | undefined { if (!queryJSON.rawFilterList || queryJSON.rawFilterList.length === 0) { return undefined; @@ -2526,30 +2567,6 @@ function getEmptyDateValues(): SearchDateValues { }; } -/** - * Returns an object containing the filter values needed to reset - * the currently applied advanced filters back to their initial state. - * - * - STATUS is reset to `ALL` - * - TYPE is reset to `EXPENSE` - * - COLUMNS is reset to undefined only if the current TYPE is not EXPENSE - * - Other filters are reset to `undefined` - */ -function getAdvancedFiltersToReset(searchAdvancedFiltersForm: Partial) { - const isTypeExpense = searchAdvancedFiltersForm.type === CONST.SEARCH.DATA_TYPES.EXPENSE; - return Object.keys(searchAdvancedFiltersForm).reduce((acc, filterKey) => { - if (filterKey === FILTER_KEYS.TYPE) { - if (!isTypeExpense) { - acc[filterKey] = CONST.SEARCH.DATA_TYPES.EXPENSE; - } - } else if (filterKey !== FILTER_KEYS.COLUMNS || !isTypeExpense) { - Object.assign(acc, {[filterKey]: undefined}); - } - - return acc; - }, {} as Partial); -} - /** * Set of filter keys that represent free-text fields where the default `:` (eq) operator * should be treated as a substring/partial match (`contains`) when querying the backend. @@ -2643,11 +2660,52 @@ function getFilterFormValues filter.key)); + const defaultQueryFilterKeys = new Set(defaultQueryJSON.flatFilters.map((filter) => filter.key)); + + return [...defaultQueryFilterKeys].every((value) => queryFilterKeys.has(value)) && queryJSON.type === defaultQueryJSON.type; +} + +function getValidLastQuery(lastQuery: string | undefined, defaultQuery: string) { + if (!lastQuery) { + return defaultQuery; + } + + const lastQueryJSON = buildSearchQueryJSON(lastQuery); + + if (!lastQueryJSON) { + return defaultQuery; + } + + const defaultQueryJSON = buildSearchQueryJSON(defaultQuery); + + if (!defaultQueryJSON) { + return defaultQuery; + } + + if (!doesQueryMatchDefaultFilterKeysAndType(lastQueryJSON, defaultQueryJSON)) { + return defaultQuery; + } + + return lastQuery; +} + export { getDateRangeDisplayValueFromFormValue, getRangeBoundariesFromFormValue, getRangeQueryValue, + getQueryHashWithoutFilters, getQueryHashes, + hasFiltersChangedFromDefault, withExactMatchFilterKeys, isSearchDatePreset, getDateRangeForPreset, @@ -2656,6 +2714,7 @@ export { isFilterSupported, buildSearchQueryJSON, buildSearchQueryString, + buildQueryStringWithResetFilters, buildUserReadableQueryString, buildFilterValuesString, getDisplayQueryFiltersForKey, @@ -2685,7 +2744,6 @@ export { buildOptimisticSnapshotData, getDateFilterKeys, getEmptyDateValues, - getAdvancedFiltersToReset, getDateModifierTitle, applyContainsOperatorToTextFields, serializeQueryJSONForBackend, @@ -2698,6 +2756,8 @@ export { removeNegation, getFilterFormValues, getFilterFromQuery, + getValidLastQuery, + doesQueryMatchDefaultFilterKeysAndType, queryHasSubmittedViolationFilter, }; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 56f5b595274e..bea8658fc70b 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -30,6 +30,7 @@ import type { import {GROUP_ITEM_TYPES} from '@components/Search/SearchList/ListItem/types'; import type { GroupedItem, + QueryFilterKey, QueryFilters, ReportFieldKey, ReportFieldTextKey, @@ -767,7 +768,7 @@ function getSuggestedSearches( defaultFeedID?: string, shouldShowExpensifyCard?: boolean, activeExpensifyCardFeedID?: string, -): Record, SearchTypeMenuItem> { +): Record { // Card accruals (UNAPPROVED_CARD) defaults to the active workspace's Expensify Card when it has one, // falling back to the company/bank feed otherwise. Other feed-based searches keep using `defaultFeedID`. const unapprovedCardFeedID = activeExpensifyCardFeedID ?? defaultFeedID; @@ -3397,7 +3398,7 @@ function getReportSections({ return [reportIDToTransactionsValues, reportIDToTransactionsValues.length, hasDeletedTransaction]; } -function getSelectedGroupFilterEntry(groupBy: string, groupData: unknown): {key: SearchFilterKey; value: string | number} | undefined { +function getSelectedGroupFilterEntry(groupBy: string, groupData: unknown): {key: QueryFilterKey; value: string | number} | undefined { switch (groupBy) { case CONST.SEARCH.GROUP_BY.FROM: return {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, value: (groupData as SearchMemberGroup).accountID}; @@ -3422,7 +3423,7 @@ function getSelectedGroupFilterEntry(groupBy: string, groupData: unknown): {key: } } -function buildSpecificGroupQuery(queryJSON: SearchQueryJSON, filterKey: SearchFilterKey, filterValue: string | number): SearchQueryJSON | undefined { +function buildSpecificGroupQuery(queryJSON: SearchQueryJSON, filterKey: QueryFilterKey, filterValue: string | number): SearchQueryJSON | undefined { const newFlatFilters = queryJSON.flatFilters.filter((filter) => filter.key !== filterKey); newFlatFilters.push({key: filterKey, filters: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: filterValue}]}); const newQueryJSON: SearchQueryJSON = {...queryJSON, groupBy: undefined, sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, sortOrder: CONST.SEARCH.SORT_ORDER.DESC, flatFilters: newFlatFilters}; @@ -4908,11 +4909,9 @@ type ShareProps = { */ function getOverflowMenu( icons: OverflowMenuIconsType, - itemName: string, - hash: number, - inputQuery: string, + savedSearchID: string, translate: LocalizedTranslate, - showDeleteModal: (hash: number) => void, + showDeleteModal: (savedSearchID: string) => void, isMobileMenu?: boolean, closeMenu?: () => void, shareProps?: ShareProps, @@ -4924,7 +4923,7 @@ function getOverflowMenu( if (isMobileMenu && closeMenu) { closeMenu(); } - Navigation.navigate(ROUTES.SEARCH_SAVED_SEARCH_RENAME.getRoute({name: encodeURIComponent(itemName), jsonQuery: inputQuery})); + Navigation.navigate(ROUTES.SEARCH_SAVED_SEARCH_RENAME.getRoute(savedSearchID)); }, icon: icons.Pencil, shouldShowRightIcon: false, @@ -4950,7 +4949,7 @@ function getOverflowMenu( if (isMobileMenu && closeMenu) { closeMenu(); } - showDeleteModal(hash); + showDeleteModal(savedSearchID); }, icon: icons.Trashcan, shouldShowRightIcon: false, @@ -4961,6 +4960,24 @@ function getOverflowMenu( ]; } +function savedSearchIDToSearchKey(id: string): SearchKey { + return `${CONST.SEARCH.SAVED_SEARCH_PREFIX}${id}`; +} + +/** + * Returns the last query used for a search key. + * + * A filter can also be stored as a string, which is a legacy format, so it's treated as if there is no last query. + */ +function getLastSearchQuery(searchFilters: OnyxEntry, searchKey: SearchKey): string | undefined { + const searchFilter = searchFilters?.[searchKey]; + return typeof searchFilter === 'object' ? searchFilter.query : undefined; +} + +function searchKeyToSavedSearchID(key: SearchKey | undefined) { + return key?.startsWith(CONST.SEARCH.SAVED_SEARCH_PREFIX) ? key.replace(CONST.SEARCH.SAVED_SEARCH_PREFIX, '') : undefined; +} + /** * Checks if the passed username is a correct standard username, and not a placeholder */ @@ -6128,6 +6145,16 @@ function isReportFieldKey(key: string): key is ReportFieldKey { return key.startsWith(CONST.SEARCH.REPORT_FIELD.GLOBAL_PREFIX); } +/** + * Normalizes any report field key (`reportFieldOn-`, `reportFieldNot-`, ...) to the plain `reportField-` form that + * search query filters hold. Every prefix ends in a hyphen, and the field name itself may contain hyphens, so only the + * first one is treated as the separator. + */ +function getReportFieldTextKey(key: ReportFieldKey): ReportFieldTextKey { + const reportFieldName = key.slice(key.indexOf('-') + 1); + return `${CONST.SEARCH.REPORT_FIELD.DEFAULT_PREFIX}${reportFieldName}`; +} + type SearchFilter = { key: keyof typeof FILTER_VIEW_MAP; label: string; @@ -6142,6 +6169,7 @@ function isMappedFilterKey(key: string): key is MappedFilterKey { function mapFiltersFormToLabelValueList( searchAdvancedFiltersForm: Partial, + defaultSearchQueryFilterKeys: Set, skipFilters: Set | undefined, translate: LocalizedTranslate, dateFnsLocale: DateFnsLocale | undefined, @@ -6150,23 +6178,26 @@ function mapFiltersFormToLabelValueList( ): SearchFilter[]; function mapFiltersFormToLabelValueList>( searchAdvancedFiltersForm: Partial, + defaultSearchQueryFilterKeys: Set, skipFilters: Set | undefined, translate: LocalizedTranslate, dateFnsLocale: DateFnsLocale | undefined, localeCompare: LocaleContextProps['localeCompare'], convertToDisplayStringWithoutCurrency: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency'], - mapper: (filterKey: MappedFilterKey) => T, + mapper: (filterKey: MappedFilterKey, isDefault: boolean) => T, ): Array; function mapFiltersFormToLabelValueList( searchAdvancedFiltersForm: Partial, + defaultSearchQueryFilterKeys: Set, skipFilters: Set | undefined, translate: LocalizedTranslate, dateFnsLocale: DateFnsLocale | undefined, localeCompare: LocaleContextProps['localeCompare'], convertToDisplayStringWithoutCurrency: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency'], - mapper?: (filterKey: MappedFilterKey) => Record, + mapper?: (filterKey: MappedFilterKey, isDefault: boolean) => Record, ): SearchFilter[] { - const filters: SearchFilter[] = []; + const defaultFilters: SearchFilter[] = []; + const nonDefaultFilters: SearchFilter[] = []; const addedGroups = new Set(); const type = searchAdvancedFiltersForm.type ?? CONST.SEARCH.DATA_TYPES.EXPENSE; @@ -6190,13 +6221,14 @@ function mapFiltersFormToLabelValueList( if (displayValue && label) { addedGroups.add(syntax); - filters.push({key: syntax, label: translate(label), value: displayValue, ...mapper?.(syntax)}); + const isDefault = defaultSearchQueryFilterKeys.has(syntax); + (isDefault ? defaultFilters : nonDefaultFilters).push({key: syntax, label: translate(label), value: displayValue, ...mapper?.(syntax, isDefault)}); } continue; } // Handle report field filters - only add once - if (key.startsWith(CONST.SEARCH.REPORT_FIELD.GLOBAL_PREFIX)) { + if (isReportFieldKey(key)) { if (addedGroups.has(CONST.SEARCH.REPORT_FIELD.GLOBAL_PREFIX)) { continue; } @@ -6204,8 +6236,9 @@ function mapFiltersFormToLabelValueList( const value = getReportFieldDisplayValue(searchAdvancedFiltersForm, translate, dateFnsLocale); if (value) { addedGroups.add(CONST.SEARCH.REPORT_FIELD.GLOBAL_PREFIX); - const extra = mapper?.(CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_FIELD); - filters.push({key: CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_FIELD, label: translate('workspace.common.reportField'), value, ...extra}); + const isDefault = defaultSearchQueryFilterKeys.has(getReportFieldTextKey(key)); + const extra = mapper?.(CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_FIELD, isDefault); + (isDefault ? defaultFilters : nonDefaultFilters).push({key: CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_FIELD, label: translate('workspace.common.reportField'), value, ...extra}); } continue; } @@ -6221,11 +6254,12 @@ function mapFiltersFormToLabelValueList( const label = getLabelValue(key, labelKey, translate); if (label && value && !(Array.isArray(value) && value.length === 0)) { - filters.push({key: baseKey, label, value, ...mapper?.(key)}); + const isDefault = defaultSearchQueryFilterKeys.has(baseKey); + (isDefault ? defaultFilters : nonDefaultFilters).push({key: baseKey, label, value, ...mapper?.(key, isDefault)}); } } - return filters; + return [...defaultFilters, ...nonDefaultFilters]; } function getSingleSelectFilterOptions(filterKey: SearchAdvancedFiltersKey, translate: LocalizedTranslate) { @@ -7218,6 +7252,9 @@ export { isReportActionListItemType, shouldShowYear, getOverflowMenu, + getLastSearchQuery, + savedSearchIDToSearchKey, + searchKeyToSavedSearchID, isCorrectSearchUserName, isReportActionEntry, isTaskListItemType, diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 6afa95400650..7f7ffab46f5a 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -64,7 +64,7 @@ import { } from '@libs/ReportUtils'; import {buildSearchQueryJSON, buildSearchQueryString, serializeQueryJSONForBackend} from '@libs/SearchQueryUtils'; import type {SearchKey} from '@libs/SearchUIUtils'; -import {isTransactionGroupListItemType} from '@libs/SearchUIUtils'; +import {isTransactionGroupListItemType, savedSearchIDToSearchKey} from '@libs/SearchUIUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import {cancelSpan, endSpan, startSpan} from '@libs/telemetry/activeSpans'; import {hasOnlyPendingCardTransactions} from '@libs/TransactionUtils'; @@ -849,7 +849,7 @@ function getOnyxLoadingData( return {optimisticData, finallyData, failureData}; } -function saveSearch({queryJSON, newName}: {queryJSON: Readonly; newName?: string}) { +function saveSearch({id, queryJSON, newName}: {id: string; queryJSON: Readonly; newName?: string}) { const saveSearchName = newName ?? queryJSON?.inputQuery ?? ''; const jsonQuery = JSON.stringify(queryJSON); @@ -858,7 +858,7 @@ function saveSearch({queryJSON, newName}: {queryJSON: Readonly; onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.SAVED_SEARCHES}`, value: { - [queryJSON.hash]: { + [id]: { pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, name: saveSearchName, query: queryJSON.inputQuery, @@ -872,7 +872,7 @@ function saveSearch({queryJSON, newName}: {queryJSON: Readonly; onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.SAVED_SEARCHES}`, value: { - [queryJSON.hash]: null, + [id]: null, }, }, ]; @@ -882,13 +882,13 @@ function saveSearch({queryJSON, newName}: {queryJSON: Readonly; onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.SAVED_SEARCHES}`, value: { - [queryJSON.hash]: { + [id]: { pendingAction: null, }, }, }, ]; - write(WRITE_COMMANDS.SAVE_SEARCH, {jsonQuery, newName: saveSearchName}, {optimisticData, failureData, successData}); + write(WRITE_COMMANDS.SAVE_SEARCH, {jsonQuery, savedSearchID: id, newName: saveSearchName}, {optimisticData, failureData, successData}); } function seedMyExpensesSearch(currentUserAccountID: number, searchName: string, savedSearches: OnyxEntry) { @@ -950,43 +950,50 @@ function seedMyExpensesSearch(currentUserAccountID: number, searchName: string, }, ]; - write(WRITE_COMMANDS.SAVE_SEARCH, {jsonQuery, newName: searchName}, {optimisticData, failureData, successData}); + write(WRITE_COMMANDS.SAVE_SEARCH, {jsonQuery, savedSearchID: queryJSON.hash.toString(), newName: searchName}, {optimisticData, failureData, successData}); } -function deleteSavedSearch(hash: number) { +function deleteSavedSearch(savedSearchID: string) { const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.SAVED_SEARCHES}`, + key: ONYXKEYS.SAVED_SEARCHES, value: { - [hash]: { + [savedSearchID]: { pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, }, }, }, ]; - const successData: Array> = [ + const successData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.SAVED_SEARCHES}`, + key: ONYXKEYS.SAVED_SEARCHES, + value: { + [savedSearchID]: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SEARCH_FILTERS, value: { - [hash]: null, + [savedSearchIDToSearchKey(savedSearchID)]: null, }, }, ]; const failureData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.SAVED_SEARCHES}`, + key: ONYXKEYS.SAVED_SEARCHES, value: { - [hash]: { + [savedSearchID]: { pendingAction: null, }, }, }, ]; - write(WRITE_COMMANDS.DELETE_SAVED_SEARCH, {hash}, {optimisticData, failureData, successData}); + write(WRITE_COMMANDS.DELETE_SAVED_SEARCH, {savedSearchID}, {optimisticData, failureData, successData}); } /** @@ -1207,7 +1214,7 @@ function search({ const inFlightRequestState: InFlightSearchRequest = {shouldCalculateTotals, shouldSaveRecentSearch}; inFlightSearchRequests.set(dedupeKey, inFlightRequestState); - const {optimisticData, finallyData, failureData} = getOnyxLoadingData(queryJSON.hash, queryJSON, offset, true, shouldCalculateTotals); + const onyxLoadingData = getOnyxLoadingData(queryJSON.hash, queryJSON, offset, true, shouldCalculateTotals); const {backendQueryJSON, limit, exactMatchFilterKeys} = getBackendQueryJSON(queryJSON); const query = { ...backendQueryJSON, @@ -1229,6 +1236,22 @@ function search({ }); } + const optimisticData: Array> = [...(onyxLoadingData.optimisticData ?? [])]; + const failureData: Array> = [...(onyxLoadingData.failureData ?? [])]; + const finallyData = onyxLoadingData.finallyData; + + if (searchKey) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SEARCH_FILTERS, + value: { + [searchKey]: { + query: query.inputQuery, + }, + }, + }); + } + const startRequest = () => makeRequestWithSideEffects(READ_COMMANDS.SEARCH, {hash: queryJSON.hash, jsonQuery}, {optimisticData, finallyData, failureData}) .then((result) => { diff --git a/src/pages/DynamicReportChangeWorkspacePage.tsx b/src/pages/DynamicReportChangeWorkspacePage.tsx index 593df359d575..1aa6b7e6d51c 100644 --- a/src/pages/DynamicReportChangeWorkspacePage.tsx +++ b/src/pages/DynamicReportChangeWorkspacePage.tsx @@ -117,7 +117,7 @@ function DynamicReportChangeWorkspacePage({report}: DynamicReportChangeWorkspace const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); // The snapshot keeps the report row after a workspace change, and only the server can tell whether it still matches the query. const refreshSearch = () => { diff --git a/src/pages/ReportSubmitToContent.tsx b/src/pages/ReportSubmitToContent.tsx index 53877b734fd8..5d6485e91c1b 100644 --- a/src/pages/ReportSubmitToContent.tsx +++ b/src/pages/ReportSubmitToContent.tsx @@ -103,7 +103,7 @@ function ReportSubmitToContent({ const {isOffline} = useNetwork(); const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); const lazyIllustrations = useMemoizedLazyIllustrations(['PaperAirplane']); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const hasViolations = hasViolationsReportUtils(report?.reportID, transactionViolations, currentUserDetails.accountID, currentUserDetails.login ?? ''); diff --git a/src/pages/Search/EmptySearchView.tsx b/src/pages/Search/EmptySearchView.tsx index 54f69f29f8d3..0e16ed0eb848 100644 --- a/src/pages/Search/EmptySearchView.tsx +++ b/src/pages/Search/EmptySearchView.tsx @@ -27,8 +27,8 @@ import Navigation from '@libs/Navigation/Navigation'; import {canSendInvoice, getDefaultChatEnabledPolicy, getGroupPoliciesWhereReportCanBeCreated} from '@libs/PolicyUtils'; import {generateReportID, hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils'; import {getAllPolicyValues, getFilterFromQuery, isDefaultExpenseReportsQuery, isDefaultExpensesQuery, isSearchBeforeViolationsSnapshotStarted} from '@libs/SearchQueryUtils'; -import type {SearchTypeMenuSection} from '@libs/SearchUIUtils'; import {TODO_SEARCH_KEYS} from '@libs/SearchUIUtils'; +import type {SearchTypeMenuSection} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -80,7 +80,7 @@ type EmptySearchViewItem = { function EmptySearchView({similarSearchHash, type, hasResults, queryJSON, violationSnapshotStartedAt, onScroll, contentContainerStyle}: EmptySearchViewProps) { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const {typeMenuSections} = useSearchTypeMenuSections(); + const typeMenuSections = useSearchTypeMenuSections(); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); diff --git a/src/pages/Search/SavedSearchList.tsx b/src/pages/Search/SavedSearchList.tsx index a3c5e060fb56..e2866e62771f 100644 --- a/src/pages/Search/SavedSearchList.tsx +++ b/src/pages/Search/SavedSearchList.tsx @@ -1,6 +1,7 @@ import MenuItemList from '@components/MenuItemList'; import {useSearchSidebarCollapse} from '@components/Navigation/SearchSidebarCollapseStore'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import {useSearchQueryActions, useSearchQueryContext} from '@components/Search/SearchContext'; import useDeleteSavedSearch from '@hooks/useDeleteSavedSearch'; import useFeedKeysWithAssignedCards from '@hooks/useFeedKeysWithAssignedCards'; @@ -16,8 +17,16 @@ import {setSearchContext} from '@libs/actions/Search'; import {mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; -import type {SavedSearchMenuItem} from '@libs/SearchUIUtils'; -import {createBaseSavedSearchMenuItem, getOverflowMenu as getOverflowMenuUtil, SAVED_SEARCH_FALLBACK_ICON_NAME, SAVED_SEARCH_ICON_NAMES} from '@libs/SearchUIUtils'; +import {getValidLastQuery} from '@libs/SearchQueryUtils'; +import type {SavedSearchMenuItem, SearchKey} from '@libs/SearchUIUtils'; +import { + createBaseSavedSearchMenuItem, + getLastSearchQuery, + getOverflowMenu as getOverflowMenuUtil, + savedSearchIDToSearchKey, + SAVED_SEARCH_FALLBACK_ICON_NAME, + SAVED_SEARCH_ICON_NAMES, +} from '@libs/SearchUIUtils'; import variables from '@styles/variables'; @@ -35,24 +44,35 @@ import useSavedSearchTitles from './hooks/useSavedSearchTitles'; import SavedSearchItemThreeDotMenu from './SavedSearchItemThreeDotMenu'; import SearchTypeMenuItem from './SearchTypeMenuItem'; -type SavedSearchListProps = { - hash: number | undefined; -}; - type SavedSearchMenuItemBuilderParams = { item: SaveSearchItem; + itemQuery: string; key: string; index: number; - hash: number | undefined; + currentSearchKey: SearchKey | undefined; title: string; - getOverflowMenu: (itemName: string, itemHash: number, itemQuery: string) => ReturnType; + onPress: (searchKey: SearchKey) => void; + getOverflowMenu: (itemSavedSearchID: string, itemQuery: string) => ReturnType; itemStyle: SavedSearchMenuItem['style']; isCopied: boolean; icon: IconAsset; }; -function buildSavedSearchMenuItem({item, key, index, hash, title, getOverflowMenu, itemStyle, isCopied, icon}: SavedSearchMenuItemBuilderParams): SavedSearchMenuItem & {icon: IconAsset} { - const isItemFocused = Number(key) === hash; +function buildSavedSearchMenuItem({ + item, + itemQuery, + key, + index, + currentSearchKey, + title, + onPress, + getOverflowMenu, + itemStyle, + isCopied, + icon, +}: SavedSearchMenuItemBuilderParams): SavedSearchMenuItem & {icon: IconAsset} { + const savedSearchKey = savedSearchIDToSearchKey(key); + const isItemFocused = savedSearchKey === currentSearchKey; const baseMenuItem: SavedSearchMenuItem = createBaseSavedSearchMenuItem(item, key, index, title, isItemFocused); return { @@ -62,11 +82,12 @@ function buildSavedSearchMenuItem({item, key, index, hash, title, getOverflowMen sentryLabel: CONST.SENTRY_LABEL.SEARCH.SAVED_SEARCH_MENU_ITEM, onPress: () => { setSearchContext(false); - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: item?.query ?? '', name: item?.name})); + onPress(savedSearchKey); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: itemQuery, name: item?.name})); }, rightComponent: ( @@ -75,13 +96,14 @@ function buildSavedSearchMenuItem({item, key, index, hash, title, getOverflowMen }; } -function SavedSearchList({hash}: SavedSearchListProps) { +function SavedSearchList() { const styles = useThemeStyles(); const {translate, localeCompare, formatPhoneNumber} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {isVisuallyCollapsed} = useSearchSidebarCollapse(); const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); + const [searchFilters] = useOnyx(ONYXKEYS.SEARCH_FILTERS); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const personalDetails = usePersonalDetails(); const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); @@ -92,11 +114,13 @@ function SavedSearchList({hash}: SavedSearchListProps) { const feedKeysWithCards = useFeedKeysWithAssignedCards(); const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const reportAttributes = useReportAttributes(); + const {currentSearchKey} = useSearchQueryContext(); + const {setCurrentSearchKey} = useSearchQueryActions(); const {showDeleteModal} = useDeleteSavedSearch(); const expensifyIcons = useMemoizedLazyExpensifyIcons([...SAVED_SEARCH_ICON_NAMES, 'Pencil', 'Trashcan', 'LinkCopy', 'Checkmark']); - const {copiedHash, handleShare} = useShareSavedSearch(); + const {copiedID, handleShare} = useShareSavedSearch(); const taxRates = getAllTaxRates(allPolicies); const cardsForSavedSearchDisplay = mergeCardListWithWorkspaceFeeds(workspaceCardList ?? CONST.EMPTY_OBJECT, cardList); @@ -117,10 +141,10 @@ function SavedSearchList({hash}: SavedSearchListProps) { bankAccountList, }); - const getOverflowMenu = (itemName: string, itemHash: number, itemQuery: string) => - getOverflowMenuUtil(expensifyIcons, itemName, itemHash, itemQuery, translate, showDeleteModal, false, undefined, { - onShare: () => handleShare(itemHash, itemQuery), - isCopied: copiedHash === itemHash, + const getOverflowMenu = (itemID: string, itemQuery: string) => + getOverflowMenuUtil(expensifyIcons, itemID, translate, showDeleteModal, false, undefined, { + onShare: () => handleShare(itemID, itemQuery), + isCopied: copiedID === itemID, }); const itemStyle = [styles.alignItemsCenter]; @@ -130,19 +154,22 @@ function SavedSearchList({hash}: SavedSearchListProps) { const savedSearchesMenuItems = savedSearches ? Object.entries(savedSearches) - .map(([key, item], index) => - buildSavedSearchMenuItem({ + .map(([key, item], index) => { + const itemQuery = getValidLastQuery(getLastSearchQuery(searchFilters, savedSearchIDToSearchKey(key)), item.query); + return buildSavedSearchMenuItem({ item, + itemQuery, key, index, - hash, + currentSearchKey, title: item.name === item.query ? (savedSearchTitles.get(item.query) ?? item.name) : item.name, + onPress: (savedSearchKey) => setCurrentSearchKey(savedSearchKey, itemQuery), getOverflowMenu, itemStyle, - isCopied: copiedHash === Number(key), + isCopied: copiedID === key, icon: expensifyIcons[savedSearchIconNames.get(item.query) ?? SAVED_SEARCH_FALLBACK_ICON_NAME], - }), - ) + }); + }) .sort((a, b) => localeCompare(a.title ?? '', b.title ?? '')) : []; diff --git a/src/pages/Search/SavedSearchRenamePage.tsx b/src/pages/Search/SavedSearchRenamePage.tsx index 0d06f2abbb6e..c4772e7f9822 100644 --- a/src/pages/Search/SavedSearchRenamePage.tsx +++ b/src/pages/Search/SavedSearchRenamePage.tsx @@ -1,3 +1,4 @@ +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; @@ -8,27 +9,39 @@ import TextInput from '@components/TextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {saveSearch} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; -import {buildCannedSearchQuery, buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SearchSavedSearchParamList} from '@libs/Navigation/types'; +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import {getFieldRequiredErrors} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/SearchSavedSearchRenameForm'; import React from 'react'; -function SavedSearchRenamePage({route}: {route: {params: {q: string; name: string}}}) { +type SavedSearchRenamePageProps = PlatformStackScreenProps; + +function SavedSearchRenamePage({route}: SavedSearchRenamePageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const {q, name} = route.params; + const {id} = route.params; + const [savedSearch] = useOnyx(ONYXKEYS.SAVED_SEARCHES, {selector: (savedSearches) => savedSearches?.[id]}); + const q = savedSearch?.query; const {inputCallbackRef} = useAutoFocusInput(); const applyFiltersAndNavigate = (newName: string) => { + if (!q) { + return; + } + Navigation.dismissModal(); Navigation.isNavigationReady().then(() => { Navigation.navigate( @@ -41,10 +54,15 @@ function SavedSearchRenamePage({route}: {route: {params: {q: string; name: strin }; const onSaveSearch = (values: FormOnyxValues) => { + if (!q) { + return; + } + const newName = values[INPUT_IDS.NAME].trim(); - const queryJSON = buildSearchQueryJSON(q || buildCannedSearchQuery()) ?? ({} as SearchQueryJSON); + const queryJSON = buildSearchQueryJSON(q) ?? ({} as SearchQueryJSON); saveSearch({ + id, queryJSON, newName, }); @@ -62,26 +80,28 @@ function SavedSearchRenamePage({route}: {route: {params: {q: string; name: strin offlineIndicatorStyle={styles.mtAuto} includeSafeAreaPaddingBottom > - - - - + + + + + + ); } diff --git a/src/pages/Search/SearchAdvancedFiltersProvider.tsx b/src/pages/Search/SearchAdvancedFiltersProvider.tsx index 109be7adb121..d352c9f07146 100644 --- a/src/pages/Search/SearchAdvancedFiltersProvider.tsx +++ b/src/pages/Search/SearchAdvancedFiltersProvider.tsx @@ -5,11 +5,13 @@ import useOnyx from '@hooks/useOnyx'; import {setSearchContext} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; -import {getAdvancedFiltersToReset} from '@libs/SearchQueryUtils'; +import {buildQueryStringWithResetFilters, hasFiltersChangedFromDefault} from '@libs/SearchQueryUtils'; +import {shouldShowFilter, SKIPPED_SEARCH_FILTERS} from '@libs/SearchUIUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {SearchAdvancedFiltersKey} from '@src/types/form/SearchAdvancedFiltersForm'; +import ObjectUtils from '@src/types/utils/ObjectUtils'; import React, {useState} from 'react'; @@ -41,24 +43,23 @@ type SearchAdvancedFiltersProviderProps = { function SearchAdvancedFiltersProvider({children}: SearchAdvancedFiltersProviderProps) { const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); - const {currentSearchQueryJSON} = useSearchQueryContext(); + const {currentDefaultSearchQueryJSON, currentSearchQueryJSON} = useSearchQueryContext(); const {getUpdatedFilterFormValues, setFilterQueryParams} = useUpdateFilterQuery(currentSearchQueryJSON); const [values, setValues] = useState>(searchAdvancedFiltersForm ?? {}); - const advancedFiltersToReset = searchAdvancedFiltersForm ? getAdvancedFiltersToReset(searchAdvancedFiltersForm) : undefined; - const applyFilters = () => { Navigation.dismissModal({afterTransition: () => setFilterQueryParams(values)}); }; const resetFilters = () => { - if (!advancedFiltersToReset) { + if (!currentSearchQueryJSON) { return; } + Navigation.dismissModal({ afterTransition: () => { - setFilterQueryParams(advancedFiltersToReset); + Navigation.setParams({q: buildQueryStringWithResetFilters(currentSearchQueryJSON, currentDefaultSearchQueryJSON), rawQuery: undefined}); setSearchContext(false); }, }); @@ -70,7 +71,13 @@ function SearchAdvancedFiltersProvider({children}: SearchAdvancedFiltersProvider const searchAdvancedFiltersValue: SearchAdvancedFiltersValue = { currentDraftFilters: values, - shouldShowResetFilters: !isEmptyObject(advancedFiltersToReset), + shouldShowResetFilters: + currentDefaultSearchQueryJSON && currentSearchQueryJSON + ? hasFiltersChangedFromDefault(currentSearchQueryJSON, currentDefaultSearchQueryJSON) + : !!searchAdvancedFiltersForm && + ObjectUtils.typedKeys(searchAdvancedFiltersForm).filter((key) => + shouldShowFilter(SKIPPED_SEARCH_FILTERS, key, searchAdvancedFiltersForm?.[key], searchAdvancedFiltersForm?.type), + ).length > 0, }; const searchAdvancedFiltersActionValue: SearchAdvancedFiltersActionValue = { diff --git a/src/pages/Search/SearchPageNarrow/StaticSearchTypeMenu.tsx b/src/pages/Search/SearchPageNarrow/StaticSearchTypeMenu.tsx index 25af4cd9ff2b..8dede1a36f5e 100644 --- a/src/pages/Search/SearchPageNarrow/StaticSearchTypeMenu.tsx +++ b/src/pages/Search/SearchPageNarrow/StaticSearchTypeMenu.tsx @@ -1,10 +1,14 @@ +// Static twin of SearchTypeMenuNarrow - used for fast perceived performance. +// Keep hooks and Onyx subscriptions to an absolute minimum; add new ones only +// when strictly necessary. UI must stay visually identical to the interactive version. import {useSession} from '@components/OnyxListItemProvider'; +import {useSearchQueryContext} from '@components/Search/SearchContext'; import type {SearchQueryJSON} from '@components/Search/types'; import type {TabSelectorBaseItem} from '@components/TabSelector/types'; +import useActiveSavedSearch from '@hooks/useActiveSavedSearch'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import type {SearchKey, SearchTypeMenuItem} from '@libs/SearchUIUtils'; @@ -14,13 +18,7 @@ import {SearchTypeMenuNarrowContent} from '@pages/Search/SearchTypeMenuNarrow'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {SaveSearch} from '@src/types/onyx'; -import type {OnyxEntry} from 'react-native-onyx'; - -// Static twin of SearchTypeMenuNarrow - used for fast perceived performance. -// Keep hooks and Onyx subscriptions to an absolute minimum; add new ones only -// when strictly necessary. UI must stay visually identical to the interactive version. import React from 'react'; import staticPolicyInfoSelector from './staticPolicyInfoSelector'; @@ -33,32 +31,12 @@ function getActiveKey(similarSearchHash: number, hasGroupPolicy: boolean, search return candidates.find((entry) => similarSearchHash === entry.similarSearchHash)?.key ?? reportsSearch.key; } -function getActiveSavedSearch(savedSearches: OnyxEntry, hash: number, isOffline: boolean): {key: string; title: string; query: string} | undefined { - if (!savedSearches) { - return undefined; - } - const entry = Object.entries(savedSearches).find(([key, item]) => { - if (Number(key) !== hash) { - return false; - } - if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !isOffline) { - return false; - } - return true; - }); - if (!entry) { - return undefined; - } - const [key, item] = entry; - return {key, title: item.name || item.query || key, query: item.query}; -} - function StaticSearchTypeMenu({queryJSON}: {queryJSON: SearchQueryJSON}) { const {translate} = useLocalize(); - const {isOffline} = useNetwork(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Receipt', 'Document', 'Pencil', ...SAVED_SEARCH_ICON_NAMES]); + const {currentSearchKey} = useSearchQueryContext(); const [policyInfo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: staticPolicyInfoSelector}); - const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); + const activeSavedSearch = useActiveSavedSearch(); const hasGroupPolicy = policyInfo?.hasGroupPolicy ?? false; const session = useSession(); const accountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID; @@ -68,8 +46,7 @@ function StaticSearchTypeMenu({queryJSON}: {queryJSON: SearchQueryJSON}) { const expensesSearch = suggestedSearches[CONST.SEARCH.SEARCH_KEYS.EXPENSES]; const submitSearch = suggestedSearches[CONST.SEARCH.SEARCH_KEYS.SUBMIT]; - // Saved searches are keyed by their raw hash rather than by a SearchKey, so the tab keys widen to string. - const tabs: TabSelectorBaseItem[] = [ + const tabs: Array> = [ {key: reportsSearch.key, icon: expensifyIcons.Document, title: translate(reportsSearch.translationPath)}, {key: expensesSearch.key, icon: expensifyIcons.Receipt, title: translate(expensesSearch.translationPath)}, ]; @@ -78,12 +55,11 @@ function StaticSearchTypeMenu({queryJSON}: {queryJSON: SearchQueryJSON}) { tabs.push({key: submitSearch.key, icon: expensifyIcons.Pencil, title: translate(submitSearch.translationPath)}); } - const activeSavedSearch = getActiveSavedSearch(savedSearches, queryJSON.hash, isOffline); - if (activeSavedSearch) { - tabs.push({key: activeSavedSearch.key, icon: expensifyIcons[getSavedSearchIconName(activeSavedSearch.query)], title: activeSavedSearch.title}); + if (activeSavedSearch && currentSearchKey) { + tabs.push({key: currentSearchKey, icon: expensifyIcons[getSavedSearchIconName(activeSavedSearch.query)], title: activeSavedSearch.name}); } - const activeKey = activeSavedSearch?.key ?? getActiveKey(queryJSON.similarSearchHash, hasGroupPolicy, suggestedSearches); + const activeKey = activeSavedSearch ? currentSearchKey : getActiveKey(queryJSON.similarSearchHash, hasGroupPolicy, suggestedSearches); return ( subtree from re-rendering; // verified via profiling), so the heavy list is unaffected. - const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, queryJSON?.hash, true); + const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, true); const shouldReserveFooterSpace = hasSelectedTransactions || (shouldAllowFooterTotals && !!searchResults?.search?.count); const {saveScrollOffset} = useContext(ScrollOffsetContext); const receiptDropTargetRef = useRef(null); diff --git a/src/pages/Search/SearchSavePage.tsx b/src/pages/Search/SearchSavePage.tsx index a2745acb261d..b91b2d5210ea 100644 --- a/src/pages/Search/SearchSavePage.tsx +++ b/src/pages/Search/SearchSavePage.tsx @@ -11,7 +11,7 @@ import useFilterReportValue from '@components/Search/hooks/useFilterReportValue' import useFilterTaxRateValue from '@components/Search/hooks/useFilterTaxRateValue'; import useFilterUserValue from '@components/Search/hooks/useFilterUserValue'; import useFilterWorkspaceValue from '@components/Search/hooks/useFilterWorkspaceValue'; -import {useSearchQueryContext} from '@components/Search/SearchContext'; +import {useSearchQueryActions, useSearchQueryContext} from '@components/Search/SearchContext'; import type {SearchQueryJSON} from '@components/Search/types'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; @@ -24,7 +24,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {saveSearch} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; -import {getCustomColumnDefault, getSearchColumnTranslationKey, mapFiltersFormToLabelValueList} from '@libs/SearchUIUtils'; +import {rand64} from '@libs/NumberUtils'; +import {getCustomColumnDefault, getSearchColumnTranslationKey, mapFiltersFormToLabelValueList, savedSearchIDToSearchKey} from '@libs/SearchUIUtils'; import type {SearchFilter} from '@libs/SearchUIUtils'; import {getFieldRequiredErrors} from '@libs/ValidationUtils'; @@ -162,7 +163,8 @@ function SearchSavePage() { const {convertToDisplayStringWithoutCurrency} = useCurrencyListActions(); const [searchAdvancedFiltersForm = getEmptyObject>()] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); - const {currentSearchQueryJSON} = useSearchQueryContext(); + const {currentDefaultSearchQueryFilterKeys, currentSearchQueryJSON} = useSearchQueryContext(); + const {setCurrentSearchKey} = useSearchQueryActions(); const onSaveSearch = (values: FormOnyxValues) => { if (!currentSearchQueryJSON) { @@ -170,14 +172,24 @@ function SearchSavePage() { return; } - saveSearch({queryJSON: currentSearchQueryJSON, newName: values[INPUT_IDS.NAME].trim()}); + const id = rand64(); + setCurrentSearchKey(savedSearchIDToSearchKey(id)); + saveSearch({id, queryJSON: currentSearchQueryJSON, newName: values[INPUT_IDS.NAME].trim()}); Navigation.goBack(); }; const validate = (values: FormOnyxValues): FormInputErrors => getFieldRequiredErrors(values, [INPUT_IDS.NAME], translate); - const appliedFilters = mapFiltersFormToLabelValueList(searchAdvancedFiltersForm, undefined, translate, dateFnsLocale, localeCompare, convertToDisplayStringWithoutCurrency); + const appliedFilters = mapFiltersFormToLabelValueList( + searchAdvancedFiltersForm, + currentDefaultSearchQueryFilterKeys, + undefined, + translate, + dateFnsLocale, + localeCompare, + convertToDisplayStringWithoutCurrency, + ); const appliedDisplays = getAppliedDisplays(searchAdvancedFiltersForm, currentSearchQueryJSON, translate); const {inputCallbackRef} = useAutoFocusInput(); diff --git a/src/pages/Search/SearchTypeMenuNarrow.tsx b/src/pages/Search/SearchTypeMenuNarrow.tsx index 63f43d68c91c..83cda8a966f6 100644 --- a/src/pages/Search/SearchTypeMenuNarrow.tsx +++ b/src/pages/Search/SearchTypeMenuNarrow.tsx @@ -2,6 +2,7 @@ import type BaseModalProps from '@components/Modal/types'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import PopoverMenu from '@components/PopoverMenu'; import type {PopoverMenuItem} from '@components/PopoverMenu'; +import {useSearchQueryActions, useSearchQueryContext} from '@components/Search/SearchContext'; import type {SearchQueryJSON} from '@components/Search/types'; import TabSelectorBase from '@components/TabSelector/TabSelectorBase'; import TabSelectorContextProvider from '@components/TabSelector/TabSelectorContext'; @@ -22,7 +23,17 @@ import useTodoCounts from '@hooks/useTodoCounts'; import {setSearchContext} from '@libs/actions/Search'; import {mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; import {getAllTaxRates} from '@libs/PolicyUtils'; -import {getItemBadgeText, getOverflowMenu, SAVED_SEARCH_FALLBACK_ICON_NAME, SAVED_SEARCH_ICON_NAMES, SEARCH_TYPE_MENU_ICON_NAMES} from '@libs/SearchUIUtils'; +import {getValidLastQuery} from '@libs/SearchQueryUtils'; +import { + getItemBadgeText, + getLastSearchQuery, + getOverflowMenu, + savedSearchIDToSearchKey, + SAVED_SEARCH_FALLBACK_ICON_NAME, + SAVED_SEARCH_ICON_NAMES, + SEARCH_TYPE_MENU_ICON_NAMES, +} from '@libs/SearchUIUtils'; +import type {SearchKey} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -44,11 +55,11 @@ type SearchTypeMenuNarrowProps = { }; type SearchTypeMenuNarrowContentProps = { - tabs: TabSelectorBaseItem[]; - activeTabKey: string; - onActiveTabPress?: (key: string) => void; - onTabPress?: (key: string) => void; - onLongTabPress?: (key: string) => void; + tabs: Array>; + activeTabKey: SearchKey | undefined; + onActiveTabPress?: (key: SearchKey) => void; + onTabPress?: (key: SearchKey) => void; + onLongTabPress?: (key: SearchKey) => void; containerRef?: React.RefObject; children?: React.ReactNode; }; @@ -81,16 +92,7 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps const {translate, localeCompare, formatPhoneNumber} = useLocalize(); const styles = useThemeStyles(); const isFocused = useIsFocused(); - const {typeMenuSections, activeKey: activeTypeMenuKey} = useSearchTypeMenuSections( - { - hash: queryJSON?.hash, - similarSearchHash: queryJSON?.similarSearchHash, - sortBy: queryJSON?.sortBy, - sortOrder: queryJSON?.sortOrder, - type: queryJSON?.type, - }, - isFocused, - ); + const typeMenuSections = useSearchTypeMenuSections(isFocused); const personalDetails = usePersonalDetails(); const feedKeysWithCards = useFeedKeysWithAssignedCards(); const [restoreFocusType, setRestoreFocusType] = useState(); @@ -102,9 +104,12 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [workspaceCardList] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST); const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); + const [searchFilters] = useOnyx(ONYXKEYS.SEARCH_FILTERS); const {counts: reportCounts} = useTodoCounts(isFocused); const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const reportAttributes = useReportAttributes(); + const {currentSearchKey} = useSearchQueryContext(); + const {setCurrentSearchKey} = useSearchQueryActions(); const taxRates = getAllTaxRates(allPolicies); const cardsForSavedSearchDisplay = mergeCardListWithWorkspaceFeeds(workspaceCardList ?? CONST.EMPTY_OBJECT, cardList); @@ -125,47 +130,42 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps enabled: !!queryJSON, }); - const [savedSearchToModifyKey, setSavedSearchToModifyKey] = useState(null); + const [savedSearchToModifyKey, setSavedSearchToModifyKey] = useState(null); const menuAnchorRef = useRef(null); const {showDeleteModal} = useDeleteSavedSearch(); - const {copiedHash, handleShare} = useShareSavedSearch(); + const {copiedID, handleShare} = useShareSavedSearch(); const expensifyIcons = useMemoizedLazyExpensifyIcons([...SEARCH_TYPE_MENU_ICON_NAMES, ...SAVED_SEARCH_ICON_NAMES, 'Trashcan', 'LinkCopy', 'Checkmark']); // Resolve each saved search's icon once per collection change (see useSavedSearchIcons for why). const savedSearchIconNames = useSavedSearchIcons(savedSearches); - const queryMap = new Map(); - const tabItems: TabSelectorBaseItem[] = []; - const savedSearchesPopoverMenuItems: Record = {}; - let activeKey = ''; + const queryMap = new Map(); + const tabItems: Array> = []; + const savedSearchesPopoverMenuItems: Partial> = {}; - const savedSearchesTabItems: TabSelectorBaseItem[] = savedSearches + const savedSearchesTabItems: Array> = savedSearches ? Object.entries(savedSearches) - .map(([key, item]): TabSelectorBaseItem | null => { + .map(([key, item]): TabSelectorBaseItem | null => { if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !isOffline) { return null; } const title = item.name === item.query ? (savedSearchTitles.get(item.query) ?? item.name) : item.name; - queryMap.set(key, {query: item.query ?? '', name: item.name}); - const itemHash = Number(key); - savedSearchesPopoverMenuItems[key] = getOverflowMenu(expensifyIcons, title, itemHash, item.query, translate, showDeleteModal, true, () => setSavedSearchToModifyKey(null), { + const savedSearchKey = savedSearchIDToSearchKey(key); + queryMap.set(savedSearchKey, {query: item.query ?? '', name: item.name}); + savedSearchesPopoverMenuItems[savedSearchKey] = getOverflowMenu(expensifyIcons, key, translate, showDeleteModal, true, () => setSavedSearchToModifyKey(null), { onShare: () => { - handleShare(itemHash, item.query); + handleShare(key, item.query); setTimeout(() => setSavedSearchToModifyKey(null), MENU_CLOSE_DELAY_MS); }, - isCopied: copiedHash === itemHash, + isCopied: copiedID === key, }); - if (Number(key) === queryJSON?.hash) { - activeKey = key; - } - return { - key, + key: savedSearchKey, icon: expensifyIcons[savedSearchIconNames.get(item.query) ?? SAVED_SEARCH_FALLBACK_ICON_NAME], title, isDisabled: item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, @@ -195,41 +195,41 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps badgeStyles: styles.tabSelectorBadge, }); queryMap.set(item.key, {query: item.searchQuery}); - if (item.key === activeTypeMenuKey) { - activeKey = item.key; - } } } } - const popoverMenuItems = savedSearchToModifyKey ? savedSearchesPopoverMenuItems?.[savedSearchToModifyKey] : []; + const popoverMenuItems = savedSearchToModifyKey ? (savedSearchesPopoverMenuItems?.[savedSearchToModifyKey] ?? []) : []; const shouldShowSavedSearchPopover = savedSearchToModifyKey && popoverMenuItems.length > 0; - const handleActiveTabPress = (tabKey: string) => { + const handleActiveTabPress = (tabKey: SearchKey) => { const searchData = queryMap.get(tabKey); if (!searchData) { return; } onTabPress?.(); + setCurrentSearchKey(tabKey); setSearchContext(false); }; - const handleTabPress = (tabKey: string) => { + const handleTabPress = (tabKey: SearchKey) => { const searchData = queryMap.get(tabKey); if (!searchData) { return; } onTabPress?.(); + const query = getValidLastQuery(getLastSearchQuery(searchFilters, tabKey), searchData.query); + setCurrentSearchKey(tabKey, query); setSearchContext(false); navigation.dispatch({ type: CONST.NAVIGATION.ACTION_TYPE.PUSH_PARAMS, payload: { - params: {q: searchData.query, name: searchData.name, rawQuery: undefined}, + params: {q: query, name: searchData.name, rawQuery: undefined}, }, }); }; - const handleLongTabPress = (tabKey: string) => { + const handleLongTabPress = (tabKey: SearchKey) => { if (!savedSearchesPopoverMenuItems?.[tabKey]) { return; } @@ -240,7 +240,7 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps return ( void; + onItemPress: (key: SearchKey, query: string) => void; }; -function Section({section, hash, activeItemIndex, sectionStartIndex, reportCounts, onItemPress}: SectionParams) { +function Section({section, reportCounts, onItemPress}: SectionParams) { const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons(SEARCH_TYPE_MENU_ICON_NAMES); + const {currentSearchKey} = useSearchQueryContext(); + const [isExpanded, setIsExpanded] = useState(true); const isSavedSearchesSection = section.translationPath === 'search.savedSearchesMenuItemTitle'; @@ -56,11 +53,10 @@ function Section({section, hash, activeItemIndex, sectionStartIndex, reportCount title={translate(section.translationPath)} badgeText={getSectionBadgeText(section.translationPath, reportCounts)} > - {isSavedSearchesSection && } + {isSavedSearchesSection && } {!isSavedSearchesSection && - section.menuItems.map((item, itemIndex) => { - const flattenedIndex = sectionStartIndex + itemIndex; - const focused = activeItemIndex === flattenedIndex; + section.menuItems.map((item) => { + const focused = item.key === currentSearchKey; const icon = typeof item.icon === 'string' ? expensifyIcons[item.icon] : item.icon; return ( @@ -70,7 +66,7 @@ function Section({section, hash, activeItemIndex, sectionStartIndex, reportCount icon={icon} badgeText={getItemBadgeText(item.key, reportCounts)} focused={focused} - onPress={() => onItemPress(item.searchQuery)} + onPress={() => onItemPress(item.key, item.searchQuery)} /> ); })} @@ -78,13 +74,13 @@ function Section({section, hash, activeItemIndex, sectionStartIndex, reportCount ); } -function SearchTypeMenuWide({queryJSON}: SearchTypeMenuProps) { - const {hash, similarSearchHash, sortBy, sortOrder, type} = queryJSON ?? {}; - +function SearchTypeMenuWide() { const styles = useThemeStyles(); const {singleExecution} = useSingleExecution(); const {clearSelectedTransactions} = useSearchSelectionActions(); - const {typeMenuSections, activeItemIndex} = useSearchTypeMenuSections({hash, similarSearchHash, sortBy, sortOrder, type}); + const typeMenuSections = useSearchTypeMenuSections(); + const {setCurrentSearchKey} = useSearchQueryActions(); + const [searchFilters] = useOnyx(ONYXKEYS.SEARCH_FILTERS); // Intentionally left enabled (no focus freeze): the wide menu renders in the search navigator's ExtraContent // slot, where useIsFocused() does not track visibility, so freezing on it would be unreliable. const {counts: reportCounts} = useTodoCounts(); @@ -101,7 +97,9 @@ function SearchTypeMenuWide({queryJSON}: SearchTypeMenuProps) { saveScrollOffset(route, e.nativeEvent.contentOffset.y); }; - const handleTypeMenuItemPress = singleExecution((searchQuery: string) => navigateToCannedSpendSearch(searchQuery, clearSelectedTransactions)); + const handleTypeMenuItemPress = singleExecution((searchKey: SearchKey, searchQuery: string) => { + navigateToCannedSpendSearch(searchKey, searchQuery, getLastSearchQuery(searchFilters, searchKey), clearSelectedTransactions, setCurrentSearchKey); + }); useLayoutEffect(() => { const scrollOffset = getScrollOffset(route); @@ -111,10 +109,6 @@ function SearchTypeMenuWide({queryJSON}: SearchTypeMenuProps) { scrollViewRef.current.scrollTo({y: scrollOffset, animated: false}); }, [getScrollOffset, route]); - const sectionStartIndices = [0]; - for (const section of typeMenuSections) { - sectionStartIndices.push((sectionStartIndices.at(-1) ?? 0) + section.menuItems.length); - } const expenseReportsSection = typeMenuSections.find((section) => section.translationPath === 'search.tabs.expenseReports'); const nonExpenseReportsSections = typeMenuSections.filter((section) => section.translationPath !== 'search.tabs.expenseReports'); @@ -129,21 +123,15 @@ function SearchTypeMenuWide({queryJSON}: SearchTypeMenuProps) {
)} - {nonExpenseReportsSections.map((section, index) => ( + {nonExpenseReportsSections.map((section) => (
))} diff --git a/src/selectors/SearchFilters.ts b/src/selectors/SearchFilters.ts new file mode 100644 index 000000000000..ffa126f46f4d --- /dev/null +++ b/src/selectors/SearchFilters.ts @@ -0,0 +1,11 @@ +import {getLastSearchQuery} from '@libs/SearchUIUtils'; + +import CONST from '@src/CONST'; +import type SearchFilters from '@src/types/onyx/SearchFilters'; + +import type {OnyxEntry} from 'react-native-onyx'; + +const lastExpensesSearchQuerySelector = (searchFilters: OnyxEntry): string | undefined => getLastSearchQuery(searchFilters, CONST.SEARCH.SEARCH_KEYS.EXPENSES); + +// eslint-disable-next-line import/prefer-default-export -- additional selectors may be added here +export {lastExpensesSearchQuerySelector}; diff --git a/src/styles/index.ts b/src/styles/index.ts index dab1b938ea21..e54c8682cae4 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5476,7 +5476,7 @@ const staticStyles = (theme: ThemeColors) => alignSelf: 'flex-start', }, - searchFiltersClearButton: { + searchFiltersResetButton: { flexDirection: 'row', gap: 4, alignItems: 'center', diff --git a/src/types/onyx/SaveSearch.ts b/src/types/onyx/SaveSearch.ts index 6b3a903b1639..286d0f5dc504 100644 --- a/src/types/onyx/SaveSearch.ts +++ b/src/types/onyx/SaveSearch.ts @@ -14,6 +14,6 @@ type SaveSearchItem = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** * Model of saved searches */ -type SaveSearch = Record; +type SaveSearch = Record; export type {SaveSearch, SaveSearchItem}; diff --git a/src/types/onyx/SearchFilters.ts b/src/types/onyx/SearchFilters.ts new file mode 100644 index 000000000000..989cfe39f126 --- /dev/null +++ b/src/types/onyx/SearchFilters.ts @@ -0,0 +1,14 @@ +import type {SearchKey} from '@libs/SearchUIUtils'; + +/** Filter criteria for a specific search key. */ +type SearchFilter = { + /** Timestamp when the filter was created or updated. */ + timestamp: string; + /** Query used for the filter. */ + query: string; +}; + +/** Collection of search filters keyed by search key. */ +type SearchFilters = Record; + +export default SearchFilters; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index f5be6d0a8d3c..7bc05d1960bd 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -176,6 +176,7 @@ import type {SaveSearch} from './SaveSearch'; import type ScheduleCallDraft from './ScheduleCallDraft'; import type ScreenShareRequest from './ScreenShareRequest'; import type SearchContext from './SearchContext'; +import type SearchFilters from './SearchFilters'; import type SearchFooterConversion from './SearchFooterConversion'; import type SearchResults from './SearchResults'; import type SearchSidebar from './SearchSidebar'; @@ -403,6 +404,7 @@ export type { WorkspaceCardFeedsStatus, DomainSettings, SaveSearch, + SearchFilters, RecentSearchItem, SearchContext, SearchFooterConversion, diff --git a/tests/ui/CategoryListItemHeaderTest.tsx b/tests/ui/CategoryListItemHeaderTest.tsx index 35b4915bc71a..71baae5f3989 100644 --- a/tests/ui/CategoryListItemHeaderTest.tsx +++ b/tests/ui/CategoryListItemHeaderTest.tsx @@ -32,6 +32,8 @@ const mockSearchStateContext = { currentSearchHash: 12345, currentSearchKey: undefined, currentSearchQueryJSON: undefined, + currentDefaultSearchQueryJSON: undefined, + currentDefaultSearchQueryFilterKeys: new Set(), currentSearchResults: undefined, currentSearchTransactionsByReportID: new Map(), currentSearchViolations: {}, @@ -64,6 +66,8 @@ const mockSearchActionsContext = { selectAllMatchingItems: jest.fn(), setShouldResetSearchQuery: jest.fn(), setSortedReportIDs: jest.fn(), + setCurrentSearchKey: jest.fn(), + resetSearchKey: jest.fn(), } satisfies SearchActionsContextValue; const createCategoryListItem = (category: string, options: Partial = {}): TransactionCategoryGroupListItemType => ({ diff --git a/tests/ui/GroupHeaderTest.tsx b/tests/ui/GroupHeaderTest.tsx index 152dafcfb582..9378fb872fbb 100644 --- a/tests/ui/GroupHeaderTest.tsx +++ b/tests/ui/GroupHeaderTest.tsx @@ -64,6 +64,8 @@ const baseState = { currentSearchTransactionsByReportID: new Map(), currentSearchViolations: {}, currentSelectedTransactionReportID: undefined, + currentDefaultSearchQueryJSON: undefined, + currentDefaultSearchQueryFilterKeys: new Set(), selectedReports: [], selectedTransactionIDs: [], selectedTransactions: {}, @@ -92,6 +94,8 @@ const baseActions = { selectAllMatchingItems: jest.fn(), setShouldResetSearchQuery: jest.fn(), setSortedReportIDs: jest.fn(), + setCurrentSearchKey: jest.fn(), + resetSearchKey: jest.fn(), } satisfies SearchActionsContextValue; function renderGroupHeader(rows: TransactionListItemType[], selection: SelectedTransactions, onCheckboxPress = jest.fn()) { diff --git a/tests/ui/MerchantListItemHeaderTest.tsx b/tests/ui/MerchantListItemHeaderTest.tsx index 7e350f87812c..254f4554f387 100644 --- a/tests/ui/MerchantListItemHeaderTest.tsx +++ b/tests/ui/MerchantListItemHeaderTest.tsx @@ -32,6 +32,8 @@ const mockSearchStateContext = { currentSearchHash: 12345, currentSearchKey: undefined, currentSearchQueryJSON: undefined, + currentDefaultSearchQueryJSON: undefined, + currentDefaultSearchQueryFilterKeys: new Set(), currentSearchResults: undefined, currentSearchTransactionsByReportID: new Map(), currentSearchViolations: {}, @@ -64,6 +66,8 @@ const mockSearchActionsContext = { selectAllMatchingItems: jest.fn(), setShouldResetSearchQuery: jest.fn(), setSortedReportIDs: jest.fn(), + setCurrentSearchKey: jest.fn(), + resetSearchKey: jest.fn(), } satisfies SearchActionsContextValue; const createMerchantListItem = (merchant: string, options: Partial = {}): TransactionMerchantGroupListItemType => ({ diff --git a/tests/ui/MonthListItemHeaderTest.tsx b/tests/ui/MonthListItemHeaderTest.tsx index 922d2ba79f46..7d7584073ee3 100644 --- a/tests/ui/MonthListItemHeaderTest.tsx +++ b/tests/ui/MonthListItemHeaderTest.tsx @@ -32,6 +32,8 @@ const mockSearchStateContext = { currentSearchHash: 12345, currentSearchKey: undefined, currentSearchQueryJSON: undefined, + currentDefaultSearchQueryJSON: undefined, + currentDefaultSearchQueryFilterKeys: new Set(), currentSearchResults: undefined, currentSearchTransactionsByReportID: new Map(), currentSearchViolations: {}, @@ -64,6 +66,8 @@ const mockSearchActionsContext = { selectAllMatchingItems: jest.fn(), setShouldResetSearchQuery: jest.fn(), setSortedReportIDs: jest.fn(), + setCurrentSearchKey: jest.fn(), + resetSearchKey: jest.fn(), } satisfies SearchActionsContextValue; const createMonthListItem = (year: number, month: number, options: Partial = {}): TransactionMonthGroupListItemType => ({ diff --git a/tests/ui/ReportListItemHeaderTest.tsx b/tests/ui/ReportListItemHeaderTest.tsx index a18354ba2524..457b64eea1b5 100644 --- a/tests/ui/ReportListItemHeaderTest.tsx +++ b/tests/ui/ReportListItemHeaderTest.tsx @@ -42,6 +42,8 @@ const mockSearchStateContext = { shouldTurnOffSelectionMode: false, currentSearchKey: undefined, currentSearchQueryJSON: undefined, + currentDefaultSearchQueryJSON: undefined, + currentDefaultSearchQueryFilterKeys: new Set(), currentSearchResults: undefined, currentSearchTransactionsByReportID: new Map(), currentSearchViolations: {}, @@ -69,6 +71,8 @@ const mockSearchActionsContext = { setShouldResetSearchQuery: jest.fn(), removeTransaction: jest.fn(), setSortedReportIDs: jest.fn(), + setCurrentSearchKey: jest.fn(), + resetSearchKey: jest.fn(), } satisfies SearchActionsContextValue; const mockPersonalDetails: Record = { diff --git a/tests/ui/WeekListItemHeaderTest.tsx b/tests/ui/WeekListItemHeaderTest.tsx index 23f78427d678..5cd9f85e10ae 100644 --- a/tests/ui/WeekListItemHeaderTest.tsx +++ b/tests/ui/WeekListItemHeaderTest.tsx @@ -31,6 +31,8 @@ const mockSearchStateContext = { currentSearchHash: 12345, currentSearchKey: undefined, currentSearchQueryJSON: undefined, + currentDefaultSearchQueryJSON: undefined, + currentDefaultSearchQueryFilterKeys: new Set(), currentSearchResults: undefined, currentSearchTransactionsByReportID: new Map(), currentSearchViolations: {}, @@ -63,6 +65,8 @@ const mockSearchActionsContext = { selectAllMatchingItems: jest.fn(), setShouldResetSearchQuery: jest.fn(), setSortedReportIDs: jest.fn(), + setCurrentSearchKey: jest.fn(), + resetSearchKey: jest.fn(), } satisfies SearchActionsContextValue; const createWeekListItem = (week: string, options: Partial = {}): TransactionWeekGroupListItemType => ({ diff --git a/tests/ui/YearListItemHeaderTest.tsx b/tests/ui/YearListItemHeaderTest.tsx index b4860545a3e4..402d4a12b8b6 100644 --- a/tests/ui/YearListItemHeaderTest.tsx +++ b/tests/ui/YearListItemHeaderTest.tsx @@ -32,6 +32,8 @@ const mockSearchStateContext = { currentSearchHash: 12345, currentSearchKey: undefined, currentSearchQueryJSON: undefined, + currentDefaultSearchQueryJSON: undefined, + currentDefaultSearchQueryFilterKeys: new Set(), currentSearchResults: undefined, currentSearchTransactionsByReportID: new Map(), currentSearchViolations: {}, @@ -64,6 +66,8 @@ const mockSearchActionsContext = { selectAllMatchingItems: jest.fn(), setShouldResetSearchQuery: jest.fn(), setSortedReportIDs: jest.fn(), + setCurrentSearchKey: jest.fn(), + resetSearchKey: jest.fn(), } satisfies SearchActionsContextValue; const createYearListItem = (year: number, options: Partial = {}): TransactionYearGroupListItemType => ({ diff --git a/tests/ui/components/SearchTypeMenuSavedSearchHighlightTest.tsx b/tests/ui/components/SearchTypeMenuSavedSearchHighlightTest.tsx index 8a3d3fd5c811..16d143a735c5 100644 --- a/tests/ui/components/SearchTypeMenuSavedSearchHighlightTest.tsx +++ b/tests/ui/components/SearchTypeMenuSavedSearchHighlightTest.tsx @@ -2,15 +2,16 @@ import {act, render, screen} from '@testing-library/react-native'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; - -import useNetwork from '@hooks/useNetwork'; +import {useSearchQueryContext} from '@components/Search/SearchContext'; +import type * as SearchContext from '@components/Search/SearchContext'; +import type {SearchQueryContextValue} from '@components/Search/types'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import {getSuggestedSearches, savedSearchIDToSearchKey} from '@libs/SearchUIUtils'; import StaticSearchTypeMenu from '@pages/Search/SearchPageNarrow/StaticSearchTypeMenu'; import SearchTypeMenuNarrow from '@pages/Search/SearchTypeMenuNarrow'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as ReactNavigation from '@react-navigation/native'; @@ -18,7 +19,6 @@ import type * as ReactNavigation from '@react-navigation/native'; import React from 'react'; import Onyx from 'react-native-onyx'; -import {translateLocal} from '../../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; jest.mock('@hooks/useTodoCounts', () => ({ @@ -35,7 +35,28 @@ jest.mock('@react-navigation/native', () => { useIsFocused: jest.fn(() => true), }; }); -jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); + +jest.mock('@components/Search/SearchContext', () => { + const actualSearchContext: typeof SearchContext = jest.requireActual('@components/Search/SearchContext'); + return { + __esModule: true, + ...actualSearchContext, + useSearchQueryContext: jest.fn(), + }; +}); + +const mockedUseSearchQueryContext = jest.mocked(useSearchQueryContext); + +const defaultSearchContext: SearchQueryContextValue = { + currentSearchHash: -1, + currentSimilarSearchHash: -1, + currentSearchKey: undefined, + currentSearchQueryJSON: undefined, + currentDefaultSearchQueryJSON: undefined, + currentDefaultSearchQueryFilterKeys: new Set(), + suggestedSearches: getSuggestedSearches(), + shouldResetSearchQuery: false, +}; function Wrapper({children}: {children: React.ReactNode}) { return ( @@ -58,7 +79,7 @@ describe('Search saved-search tab highlight', () => { jest.clearAllMocks(); }); - it('keeps saved search selected in static narrow menu when similar hash collides', async () => { + it("keeps saved search selected in static narrow menu when it's selected", async () => { const baseQuery = 'type:expense status:all'; const savedQueryString = `${baseQuery} sortBy:amount`; const savedQueryJSON = buildSearchQueryJSON(savedQueryString); @@ -67,33 +88,7 @@ describe('Search saved-search tab highlight', () => { throw new Error('Failed to build saved query JSON'); } - await act(async () => { - await Onyx.merge(ONYXKEYS.SAVED_SEARCHES, { - [savedQueryJSON.hash]: { - name: 'My saved search', - query: savedQueryString, - }, - }); - }); - - render( - - - , - ); - await waitForBatchedUpdatesWithAct(); - - expect(screen.getByRole('tab', {name: 'My saved search', selected: true})).toBeTruthy(); - }); - - it('keeps saved search selected in interactive narrow menu when similar hash collides', async () => { - const baseQuery = 'type:expense status:all'; - const savedQueryString = `${baseQuery} sortBy:amount`; - const savedQueryJSON = buildSearchQueryJSON(savedQueryString); - - if (!savedQueryJSON) { - throw new Error('Failed to build saved query JSON'); - } + mockedUseSearchQueryContext.mockReturnValue({...defaultSearchContext, currentSearchKey: savedSearchIDToSearchKey(savedQueryJSON.hash.toString())}); await act(async () => { await Onyx.merge(ONYXKEYS.SAVED_SEARCHES, { @@ -104,37 +99,6 @@ describe('Search saved-search tab highlight', () => { }); }); - render( - - - , - ); - await waitForBatchedUpdatesWithAct(); - - expect(screen.getByRole('tab', {name: 'My saved search', selected: true})).toBeTruthy(); - }); - - it('does not show pending-delete saved search as selected in static narrow menu while online', async () => { - const baseQuery = 'type:expense status:all'; - const savedQueryString = `${baseQuery} sortBy:amount`; - const savedQueryJSON = buildSearchQueryJSON(savedQueryString); - - if (!savedQueryJSON) { - throw new Error('Failed to build saved query JSON'); - } - - jest.mocked(useNetwork).mockReturnValue({isOffline: false}); - - await act(async () => { - await Onyx.merge(ONYXKEYS.SAVED_SEARCHES, { - [savedQueryJSON.hash]: { - name: 'My saved search', - query: savedQueryString, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - }, - }); - }); - render( @@ -142,10 +106,10 @@ describe('Search saved-search tab highlight', () => { ); await waitForBatchedUpdatesWithAct(); - expect(screen.queryByRole('tab', {name: 'My saved search'})).toBeNull(); + expect(screen.getByRole('tab', {name: 'My saved search', selected: true})).toBeTruthy(); }); - it('highlights built-in tab when saved search is pending delete while online (interactive narrow)', async () => { + it("keeps saved search selected in interactive narrow menu when it's selected", async () => { const baseQuery = 'type:expense status:all'; const savedQueryString = `${baseQuery} sortBy:amount`; const savedQueryJSON = buildSearchQueryJSON(savedQueryString); @@ -154,14 +118,13 @@ describe('Search saved-search tab highlight', () => { throw new Error('Failed to build saved query JSON'); } - jest.mocked(useNetwork).mockReturnValue({isOffline: false}); + mockedUseSearchQueryContext.mockReturnValue({...defaultSearchContext, currentSearchKey: savedSearchIDToSearchKey(savedQueryJSON.hash.toString())}); await act(async () => { await Onyx.merge(ONYXKEYS.SAVED_SEARCHES, { [savedQueryJSON.hash]: { name: 'My saved search', query: savedQueryString, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, }, }); }); @@ -173,7 +136,6 @@ describe('Search saved-search tab highlight', () => { ); await waitForBatchedUpdatesWithAct(); - expect(screen.queryByRole('tab', {name: 'My saved search'})).toBeNull(); - expect(screen.getByRole('tab', {name: translateLocal('search.tabs.expenses'), selected: true})).toBeTruthy(); + expect(screen.getByRole('tab', {name: 'My saved search', selected: true})).toBeTruthy(); }); }); diff --git a/tests/unit/Navigation/getLastRouteTest.ts b/tests/unit/Navigation/getLastRouteTest.ts new file mode 100644 index 000000000000..70d4e60367e1 --- /dev/null +++ b/tests/unit/Navigation/getLastRouteTest.ts @@ -0,0 +1,164 @@ +import getLastRoute from '@components/Navigation/NavigationTabBar/getLastRoute'; + +import {clearPreservedNavigatorStates, setPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +import type {NavigationState} from '@react-navigation/native'; + +type Route = NavigationState['routes'][number]; + +function createRoute(name: string, key: string, state?: NavigationState): Route { + return {key, name, ...(state ? {state} : {})} as Route; +} + +function createState(key: string, type: string, routes: Route[], index = routes.length - 1): NavigationState { + return { + stale: false, + type, + key, + index, + routeNames: routes.map((route) => route.name), + routes, + }; +} + +const createRootState = (routes: Route[]) => createState('root', 'stack', routes); +const createTabState = (key: string, routes: Route[], index?: number) => createState(key, 'tab', routes, index); +const createNavigatorState = (key: string, routes: Route[]) => createState(key, 'stack', routes); + +// getLastRoute only considers a nested navigator route that carries its own state, i.e. one that has already been rendered. +// The inner route uses the searched screen on purpose, to show that the preserved state - not the inner one - is what gets returned. +const createNavigatorRouteWithState = (navigator: string, key: string, screen: string) => + createRoute(navigator, key, createNavigatorState(`${key}-inner`, [createRoute(screen, `${key}-inner-route`)])); + +describe('getLastRoute', () => { + beforeEach(() => { + clearPreservedNavigatorStates(); + }); + + describe('navigator at the root level', () => { + it('returns the preserved screen route of the root level navigator', () => { + const rootState = createRootState([createRoute(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, 'reports-1')]); + setPreservedNavigatorState('reports-1', createNavigatorState('reports-1-state', [createRoute(SCREENS.HOME, 'home'), createRoute(SCREENS.REPORT, 'report-1')])); + + expect(getLastRoute(rootState, NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, SCREENS.REPORT)).toEqual({key: 'report-1', name: SCREENS.REPORT}); + }); + + it('uses the last navigator when the root state has several of them', () => { + const rootState = createRootState([createRoute(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, 'reports-1'), createRoute(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, 'reports-2')]); + setPreservedNavigatorState('reports-1', createNavigatorState('reports-1-state', [createRoute(SCREENS.REPORT, 'report-1')])); + setPreservedNavigatorState('reports-2', createNavigatorState('reports-2-state', [createRoute(SCREENS.REPORT, 'report-2')])); + + expect(getLastRoute(rootState, NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, SCREENS.REPORT)).toEqual({key: 'report-2', name: SCREENS.REPORT}); + }); + + it('uses the last matching screen inside the preserved navigator state', () => { + const rootState = createRootState([createRoute(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, 'reports-1')]); + setPreservedNavigatorState( + 'reports-1', + createNavigatorState('reports-1-state', [createRoute(SCREENS.REPORT, 'report-1'), createRoute(SCREENS.HOME, 'home'), createRoute(SCREENS.REPORT, 'report-2')]), + ); + + expect(getLastRoute(rootState, NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, SCREENS.REPORT)).toEqual({key: 'report-2', name: SCREENS.REPORT}); + }); + + it('takes precedence over a navigator nested in the tab navigator', () => { + const tabRoute = createRoute(NAVIGATORS.TAB_NAVIGATOR, 'tab-1', createTabState('tab-1-state', [createRoute(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, 'nested-reports')])); + const rootState = createRootState([tabRoute, createRoute(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, 'root-reports')]); + setPreservedNavigatorState('nested-reports', createNavigatorState('nested-reports-state', [createRoute(SCREENS.REPORT, 'nested-report')])); + setPreservedNavigatorState('root-reports', createNavigatorState('root-reports-state', [createRoute(SCREENS.REPORT, 'root-report')])); + + expect(getLastRoute(rootState, NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, SCREENS.REPORT)).toEqual({key: 'root-report', name: SCREENS.REPORT}); + }); + }); + + describe('navigator nested inside the tab navigator', () => { + it('returns the preserved screen route of the nested navigator', () => { + const tabRoute = createRoute( + NAVIGATORS.TAB_NAVIGATOR, + 'tab-1', + createTabState('tab-1-state', [createRoute(SCREENS.HOME, 'home'), createNavigatorRouteWithState(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, 'search-1', SCREENS.SEARCH.ROOT)]), + ); + setPreservedNavigatorState('search-1', createNavigatorState('search-1-state', [createRoute(SCREENS.SEARCH.ROOT, 'search-root-1')])); + + expect(getLastRoute(createRootState([tabRoute]), NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.SEARCH.ROOT)).toEqual({key: 'search-root-1', name: SCREENS.SEARCH.ROOT}); + }); + + it('prefers the tab navigator whose nested navigator has its own state even when it is not the focused tab', () => { + // The nested navigator is not focused (index points at Home), so it can only be found by looking at every tab route. + const tabWithSearchState = createRoute( + NAVIGATORS.TAB_NAVIGATOR, + 'tab-1', + createTabState( + 'tab-1-state', + [createRoute(SCREENS.HOME, 'home-1'), createNavigatorRouteWithState(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, 'search-1', SCREENS.SEARCH.ROOT)], + 0, + ), + ); + const tabWithoutSearch = createRoute(NAVIGATORS.TAB_NAVIGATOR, 'tab-2', createTabState('tab-2-state', [createRoute(SCREENS.HOME, 'home-2')])); + const rootState = createRootState([tabWithSearchState, tabWithoutSearch]); + setPreservedNavigatorState('search-1', createNavigatorState('search-1-state', [createRoute(SCREENS.SEARCH.ROOT, 'search-root-1')])); + + expect(getLastRoute(rootState, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.SEARCH.ROOT)).toEqual({key: 'search-root-1', name: SCREENS.SEARCH.ROOT}); + }); + + it('returns undefined when no nested navigator has its own state, even if a preserved state exists', () => { + const firstTab = createRoute(NAVIGATORS.TAB_NAVIGATOR, 'tab-1', createTabState('tab-1-state', [createRoute(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, 'search-1')])); + const lastTab = createRoute(NAVIGATORS.TAB_NAVIGATOR, 'tab-2', createTabState('tab-2-state', [createRoute(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, 'search-2')])); + const rootState = createRootState([firstTab, lastTab]); + setPreservedNavigatorState('search-1', createNavigatorState('search-1-state', [createRoute(SCREENS.SEARCH.ROOT, 'search-root-1')])); + setPreservedNavigatorState('search-2', createNavigatorState('search-2-state', [createRoute(SCREENS.SEARCH.ROOT, 'search-root-2')])); + + expect(getLastRoute(rootState, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.SEARCH.ROOT)).toBeUndefined(); + }); + + it('uses the last matching navigator inside the tab state', () => { + const tabRoute = createRoute( + NAVIGATORS.TAB_NAVIGATOR, + 'tab-1', + createTabState('tab-1-state', [ + createNavigatorRouteWithState(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, 'search-1', SCREENS.SEARCH.ROOT), + createNavigatorRouteWithState(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, 'search-2', SCREENS.SEARCH.ROOT), + ]), + ); + setPreservedNavigatorState('search-1', createNavigatorState('search-1-state', [createRoute(SCREENS.SEARCH.ROOT, 'search-root-1')])); + setPreservedNavigatorState('search-2', createNavigatorState('search-2-state', [createRoute(SCREENS.SEARCH.ROOT, 'search-root-2')])); + + expect(getLastRoute(createRootState([tabRoute]), NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.SEARCH.ROOT)).toEqual({key: 'search-root-2', name: SCREENS.SEARCH.ROOT}); + }); + + it('returns undefined when the tab navigator route has no state', () => { + const rootState = createRootState([createRoute(NAVIGATORS.TAB_NAVIGATOR, 'tab-1')]); + setPreservedNavigatorState('search-1', createNavigatorState('search-1-state', [createRoute(SCREENS.SEARCH.ROOT, 'search-root-1')])); + + expect(getLastRoute(rootState, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.SEARCH.ROOT)).toBeUndefined(); + }); + }); + + describe('when there is nothing to restore', () => { + it('returns undefined when the navigator is nowhere in the root state', () => { + const rootState = createRootState([createRoute(NAVIGATORS.TAB_NAVIGATOR, 'tab-1', createTabState('tab-1-state', [createRoute(SCREENS.HOME, 'home')]))]); + + expect(getLastRoute(rootState, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.SEARCH.ROOT)).toBeUndefined(); + }); + + it('returns undefined when the navigator has no preserved state', () => { + const rootState = createRootState([createRoute(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, 'search-1')]); + + expect(getLastRoute(rootState, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.SEARCH.ROOT)).toBeUndefined(); + }); + + it('returns undefined when the preserved state has no matching screen', () => { + const rootState = createRootState([createRoute(NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, 'search-1')]); + setPreservedNavigatorState('search-1', createNavigatorState('search-1-state', [createRoute(SCREENS.SEARCH.MONEY_REQUEST_REPORT, 'money-request-report')])); + + expect(getLastRoute(rootState, NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.SEARCH.ROOT)).toBeUndefined(); + }); + + it('returns undefined when the root state has no routes', () => { + expect(getLastRoute(createRootState([]), NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, SCREENS.SEARCH.ROOT)).toBeUndefined(); + }); + }); +}); diff --git a/tests/unit/Navigation/getSearchTabRouteTest.ts b/tests/unit/Navigation/getSearchTabRouteTest.ts index c238ab878e49..4ac22b73cced 100644 --- a/tests/unit/Navigation/getSearchTabRouteTest.ts +++ b/tests/unit/Navigation/getSearchTabRouteTest.ts @@ -40,7 +40,7 @@ describe('getSearchTabRoute', () => { return; } - expect(getSearchTabRoute(rootState, undefined)).toBe( + expect(getSearchTabRoute(rootState, undefined, undefined)).toBe( ROUTES.SEARCH_ROOT.getRoute({ query: buildSearchQueryString(queryJSON), rawQuery: q, @@ -58,12 +58,27 @@ describe('getSearchTabRoute', () => { return; } - expect(getSearchTabRoute(rootState, {queryJSON})).toBe(ROUTES.SEARCH_ROOT.getRoute({query: buildSearchQueryString(queryJSON)})); + expect(getSearchTabRoute(rootState, {queryJSON}, undefined)).toBe(ROUTES.SEARCH_ROOT.getRoute({query: buildSearchQueryString(queryJSON)})); + }); + + it('falls back to the last Expenses search query when there is no navigation route or Onyx query', () => { + const lastQuery = `${buildCannedSearchQuery({type: CONST.SEARCH.DATA_TYPES.EXPENSE})} merchant:Uber`; + mockGetLastRoute.mockReturnValue(undefined); + + expect(getSearchTabRoute(rootState, undefined, lastQuery)).toBe(ROUTES.SEARCH_ROOT.getRoute({query: lastQuery})); + }); + + it('falls back to the default expense query when the last Expenses search query is not valid', () => { + mockGetLastRoute.mockReturnValue(undefined); + + expect(getSearchTabRoute(rootState, undefined, buildCannedSearchQuery({type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT}))).toBe( + ROUTES.SEARCH_ROOT.getRoute({query: buildCannedSearchQuery({type: CONST.SEARCH.DATA_TYPES.EXPENSE})}), + ); }); it('falls back to the default expense query', () => { mockGetLastRoute.mockReturnValue(undefined); - expect(getSearchTabRoute(rootState, undefined)).toBe(ROUTES.SEARCH_ROOT.getRoute({query: buildCannedSearchQuery({type: CONST.SEARCH.DATA_TYPES.EXPENSE})})); + expect(getSearchTabRoute(rootState, undefined, undefined)).toBe(ROUTES.SEARCH_ROOT.getRoute({query: buildCannedSearchQuery({type: CONST.SEARCH.DATA_TYPES.EXPENSE})})); }); }); diff --git a/tests/unit/Search/SearchAdvancedFiltersProviderTest.tsx b/tests/unit/Search/SearchAdvancedFiltersProviderTest.tsx new file mode 100644 index 000000000000..eb1d97f02864 --- /dev/null +++ b/tests/unit/Search/SearchAdvancedFiltersProviderTest.tsx @@ -0,0 +1,191 @@ +import {renderHook} from '@testing-library/react-native'; + +import type {SearchQueryJSON} from '@components/Search/types'; + +import Navigation from '@libs/Navigation/Navigation'; +import {buildQueryStringWithResetFilters, buildSearchQueryJSON} from '@libs/SearchQueryUtils'; + +import SearchAdvancedFiltersProvider, {SearchAdvancedFiltersActionContext, SearchAdvancedFiltersContext} from '@pages/Search/SearchAdvancedFiltersProvider'; + +import CONST from '@src/CONST'; +import type {SearchAdvancedFiltersForm} from '@src/types/form'; + +import {useContext} from 'react'; + +const mockUseOnyx = jest.fn<[Partial | undefined], []>(); + +jest.mock('@hooks/useOnyx', () => ({ + __esModule: true, + default: () => mockUseOnyx(), +})); + +const mockGetUpdatedFilterFormValues = jest.fn((current: Record, next: Record) => ({...current, ...next})); +const mockSetFilterQueryParams = jest.fn(); + +jest.mock('@components/Search/hooks/useUpdateFilterQuery', () => ({ + __esModule: true, + default: () => ({getUpdatedFilterFormValues: mockGetUpdatedFilterFormValues, setFilterQueryParams: mockSetFilterQueryParams}), +})); + +const mockUseSearchQueryContext = jest.fn(); + +jest.mock('@components/Search/SearchContext', () => ({ + useSearchQueryContext: () => mockUseSearchQueryContext(), +})); + +const mockSetSearchContext = jest.fn(); + +jest.mock('@libs/actions/Search', () => ({ + setSearchContext: (...args: unknown[]) => mockSetSearchContext(...args), +})); + +jest.mock('@libs/Navigation/Navigation'); + +function useFilters() { + return {...useContext(SearchAdvancedFiltersContext), ...useContext(SearchAdvancedFiltersActionContext)}; +} + +function renderProvider() { + return renderHook(useFilters, {wrapper: SearchAdvancedFiltersProvider}); +} + +type QueryContext = { + currentSearchQueryJSON: SearchQueryJSON | undefined; + currentDefaultSearchQueryJSON: SearchQueryJSON | undefined; +}; + +function mockOnyxForm(form: Partial | undefined) { + mockUseOnyx.mockReturnValue([form]); +} + +function mockQueryContext(context: Partial) { + mockUseSearchQueryContext.mockReturnValue({ + currentSearchQueryJSON: undefined, + currentDefaultSearchQueryJSON: undefined, + ...context, + }); +} + +describe('SearchAdvancedFiltersProvider', () => { + beforeEach(() => { + mockUseOnyx.mockReset(); + mockUseSearchQueryContext.mockReset(); + mockGetUpdatedFilterFormValues.mockClear(); + mockSetFilterQueryParams.mockClear(); + mockSetSearchContext.mockClear(); + jest.mocked(Navigation.setParams).mockClear(); + // Make dismissModal run the afterTransition callback synchronously so resetFilters/applyFilters take effect. + jest.mocked(Navigation.dismissModal).mockImplementation((options?: {afterTransition?: () => void}) => options?.afterTransition?.()); + }); + + describe('shouldShowResetFilters', () => { + it('is true when the default query filters differ from the current query filters', () => { + mockOnyxForm({type: CONST.SEARCH.DATA_TYPES.EXPENSE}); + mockQueryContext({ + currentDefaultSearchQueryJSON: buildSearchQueryJSON(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`), + currentSearchQueryJSON: buildSearchQueryJSON(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon category:Food`), + }); + + const {result} = renderProvider(); + + expect(result.current.shouldShowResetFilters).toBe(true); + }); + + it('is false when the default query filters equal the current query filters', () => { + mockOnyxForm({type: CONST.SEARCH.DATA_TYPES.EXPENSE}); + mockQueryContext({ + currentDefaultSearchQueryJSON: buildSearchQueryJSON(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`), + currentSearchQueryJSON: buildSearchQueryJSON(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`), + }); + + const {result} = renderProvider(); + + expect(result.current.shouldShowResetFilters).toBe(false); + }); + + it('is false when the queries differ only by the keyword', () => { + mockOnyxForm({type: CONST.SEARCH.DATA_TYPES.EXPENSE}); + mockQueryContext({ + currentDefaultSearchQueryJSON: buildSearchQueryJSON(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`), + currentSearchQueryJSON: buildSearchQueryJSON(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon coffee`), + }); + + const {result} = renderProvider(); + + expect(result.current.shouldShowResetFilters).toBe(false); + }); + + it('is true when there is no default query JSON and non-type filter is applied', () => { + mockOnyxForm({type: CONST.SEARCH.DATA_TYPES.EXPENSE, merchant: 'Amazon'}); + mockQueryContext({currentDefaultSearchQueryJSON: undefined}); + + const {result} = renderProvider(); + + expect(result.current.shouldShowResetFilters).toBe(true); + }); + + it('is false when there is no default query JSON and only the type filter is applied', () => { + mockOnyxForm({type: CONST.SEARCH.DATA_TYPES.EXPENSE}); + mockQueryContext({currentDefaultSearchQueryJSON: undefined}); + + const {result} = renderProvider(); + + expect(result.current.shouldShowResetFilters).toBe(false); + }); + + it('is false when there is no default query JSON and no filters', () => { + mockOnyxForm(undefined); + mockQueryContext({currentDefaultSearchQueryJSON: undefined}); + + const {result} = renderProvider(); + + expect(result.current.shouldShowResetFilters).toBe(false); + }); + }); + + describe('resetFilters', () => { + function parseQuery(query: string) { + const queryJSON = buildSearchQueryJSON(query); + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + return queryJSON; + } + + const currentSearchQueryJSON = parseQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon category:Food`); + + it('navigates to the query the filters reset to', () => { + mockOnyxForm({type: CONST.SEARCH.DATA_TYPES.EXPENSE}); + const currentDefaultSearchQueryJSON = parseQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`); + mockQueryContext({currentDefaultSearchQueryJSON, currentSearchQueryJSON}); + + const {result} = renderProvider(); + result.current.resetFilters(); + + expect(Navigation.setParams).toHaveBeenCalledWith({q: buildQueryStringWithResetFilters(currentSearchQueryJSON, currentDefaultSearchQueryJSON), rawQuery: undefined}); + expect(mockSetSearchContext).toHaveBeenCalledWith(false); + }); + + it('navigates to the query the filters reset to when there is no default query', () => { + mockOnyxForm({type: CONST.SEARCH.DATA_TYPES.EXPENSE}); + mockQueryContext({currentDefaultSearchQueryJSON: undefined, currentSearchQueryJSON}); + + const {result} = renderProvider(); + result.current.resetFilters(); + + expect(Navigation.setParams).toHaveBeenCalledWith({q: buildQueryStringWithResetFilters(currentSearchQueryJSON, undefined), rawQuery: undefined}); + expect(mockSetSearchContext).toHaveBeenCalledWith(false); + }); + + it('does nothing when there is no current query', () => { + mockOnyxForm({type: CONST.SEARCH.DATA_TYPES.INVOICE}); + mockQueryContext({currentSearchQueryJSON: undefined}); + + const {result} = renderProvider(); + result.current.resetFilters(); + + expect(Navigation.setParams).not.toHaveBeenCalled(); + expect(mockSetSearchContext).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index fb757dd45cd8..8f1d36aa43d6 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -14,7 +14,7 @@ import { buildSearchQueryJSON, buildSearchQueryString, buildUserReadableQueryString, - getAdvancedFiltersToReset, + doesQueryMatchDefaultFilterKeysAndType, getAllPolicyValues, getAllPolicyValuesMap, getConnectedIntegrationNamesForPolicies, @@ -29,9 +29,13 @@ import { getKeywordQueryWithCurrentSearchContext, getLastRouteByName, getParamsState, + buildQueryStringWithResetFilters, + getQueryHashWithoutFilters, getQueryWithUpdatedValues, getRangeBoundariesFromFormValue, getRoutes, + getValidLastQuery, + hasFiltersChangedFromDefault, isFilterNegated, isDefaultExpenseReportsQuery, isDefaultExpensesQuery, @@ -2059,6 +2063,192 @@ describe('SearchQueryUtils', () => { }); }); + describe('buildQueryStringWithResetFilters', () => { + function parse(query: string) { + const queryJSON = buildSearchQueryJSON(query); + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + return queryJSON; + } + + const currentQuery = parse('type:expense category:travel merchant:Amazon group-currency:USD sortBy:merchant sortOrder:asc groupBy:from columns:merchant,category coffee'); + + it('restores the filter chips of the default query', () => { + const resetQuery = buildQueryStringWithResetFilters(currentQuery, parse('type:expense status:approved')); + + expect(resetQuery).toContain('status:approved'); + expect(resetQuery).not.toContain('category:travel'); + expect(resetQuery).not.toContain('merchant:Amazon'); + }); + + it('clears every filter chip when there is no default query', () => { + const resetQuery = buildQueryStringWithResetFilters(currentQuery, undefined); + + expect(resetQuery).not.toContain('category:travel'); + expect(resetQuery).not.toContain('merchant:Amazon'); + }); + + it('keeps the keyword, sorting, grouping, and columns, which are not filters', () => { + const resetQuery = buildQueryStringWithResetFilters(currentQuery, parse('type:expense status:approved')); + expect(resetQuery).toContain('sortBy:merchant'); + expect(resetQuery).toContain('sortOrder:asc'); + expect(resetQuery).toContain('groupBy:from'); + expect(resetQuery).toContain('groupCurrency:USD'); + expect(resetQuery).toContain('columns:merchant,category'); + expect(resetQuery).toContain('coffee'); + }); + + it('keeps the keyword and the group currency of the current query over the ones of the default query', () => { + const resetQuery = buildQueryStringWithResetFilters(currentQuery, parse('type:expense group-currency:EUR tea')); + + expect(resetQuery).toContain('groupCurrency:USD'); + expect(resetQuery).toContain('coffee'); + expect(resetQuery).not.toContain('groupCurrency:EUR'); + expect(resetQuery).not.toContain('tea'); + }); + }); + + describe('getQueryHashWithoutFilters', () => { + const noExcludedFilters = new Set(); + const excludedFilters = new Set([CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD, CONST.SEARCH.SYNTAX_FILTER_KEYS.GROUP_CURRENCY]); + + it('returns the same hash for identical queries', () => { + const queryJSONa = buildSearchQueryJSON('type:expense category:travel merchant:Amazon'); + const queryJSONb = buildSearchQueryJSON('type:expense category:travel merchant:Amazon'); + + if (!queryJSONa || !queryJSONb) { + throw new Error('Failed to parse query string'); + } + + expect(getQueryHashWithoutFilters(queryJSONa, noExcludedFilters)).toEqual(getQueryHashWithoutFilters(queryJSONb, noExcludedFilters)); + }); + + it('ignores excluded keyword filters when computing the hash', () => { + const withoutKeyword = buildSearchQueryJSON('type:expense category:travel'); + const withKeyword = buildSearchQueryJSON('type:expense category:travel hello world'); + + if (!withoutKeyword || !withKeyword) { + throw new Error('Failed to parse query string'); + } + + expect(getQueryHashWithoutFilters(withKeyword, excludedFilters)).toEqual(getQueryHashWithoutFilters(withoutKeyword, excludedFilters)); + }); + + it('ignores excluded group-currency filters when computing the hash', () => { + const withoutGroupCurrency = buildSearchQueryJSON('type:expense groupBy:category'); + const withGroupCurrency = buildSearchQueryJSON('type:expense groupBy:category group-currency:USD'); + + if (!withoutGroupCurrency || !withGroupCurrency) { + throw new Error('Failed to parse query string'); + } + + expect(getQueryHashWithoutFilters(withGroupCurrency, excludedFilters)).toEqual(getQueryHashWithoutFilters(withoutGroupCurrency, excludedFilters)); + }); + + it('takes filters that are not excluded into account', () => { + const withoutKeyword = buildSearchQueryJSON('type:expense category:travel'); + const withKeyword = buildSearchQueryJSON('type:expense category:travel hello world'); + + if (!withoutKeyword || !withKeyword) { + throw new Error('Failed to parse query string'); + } + + expect(getQueryHashWithoutFilters(withKeyword, noExcludedFilters)).not.toEqual(getQueryHashWithoutFilters(withoutKeyword, noExcludedFilters)); + }); + + it('only ignores the filters that are excluded', () => { + const onlyKeywordExcluded = new Set([CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD]); + const withoutGroupCurrency = buildSearchQueryJSON('type:expense groupBy:category hello'); + const withGroupCurrency = buildSearchQueryJSON('type:expense groupBy:category group-currency:USD'); + + if (!withoutGroupCurrency || !withGroupCurrency) { + throw new Error('Failed to parse query string'); + } + + expect(getQueryHashWithoutFilters(withGroupCurrency, onlyKeywordExcluded)).not.toEqual(getQueryHashWithoutFilters(withoutGroupCurrency, onlyKeywordExcluded)); + }); + + it('is independent of the order in which filters appear', () => { + const queryJSONa = buildSearchQueryJSON('type:expense category:travel merchant:Amazon'); + const queryJSONb = buildSearchQueryJSON('type:expense merchant:Amazon category:travel'); + + if (!queryJSONa || !queryJSONb) { + throw new Error('Failed to parse query string'); + } + + expect(getQueryHashWithoutFilters(queryJSONa, noExcludedFilters)).toEqual(getQueryHashWithoutFilters(queryJSONb, noExcludedFilters)); + }); + + it('is independent of the order of values within a filter', () => { + const queryJSONa = buildSearchQueryJSON('type:expense category:travel,food'); + const queryJSONb = buildSearchQueryJSON('type:expense category:food,travel'); + + if (!queryJSONa || !queryJSONb) { + throw new Error('Failed to parse query string'); + } + + expect(getQueryHashWithoutFilters(queryJSONa, noExcludedFilters)).toEqual(getQueryHashWithoutFilters(queryJSONb, noExcludedFilters)); + }); + + it('returns different hashes for queries with different filter values', () => { + const queryJSONa = buildSearchQueryJSON('type:expense category:travel'); + const queryJSONb = buildSearchQueryJSON('type:expense category:food'); + + if (!queryJSONa || !queryJSONb) { + throw new Error('Failed to parse query string'); + } + + expect(getQueryHashWithoutFilters(queryJSONa, noExcludedFilters)).not.toEqual(getQueryHashWithoutFilters(queryJSONb, noExcludedFilters)); + }); + + it('returns different hashes for queries with different filter keys', () => { + const queryJSONa = buildSearchQueryJSON('type:expense category:travel'); + const queryJSONb = buildSearchQueryJSON('type:expense merchant:travel'); + + if (!queryJSONa || !queryJSONb) { + throw new Error('Failed to parse query string'); + } + + expect(getQueryHashWithoutFilters(queryJSONa, noExcludedFilters)).not.toEqual(getQueryHashWithoutFilters(queryJSONb, noExcludedFilters)); + }); + }); + + describe('hasFiltersChangedFromDefault', () => { + it('returns false when the current query only differs by filters that are kept when resetting', () => { + const defaultQueryJSON = buildSearchQueryJSON('type:expense groupBy:category category:travel'); + const currentQueryJSON = buildSearchQueryJSON('type:expense groupBy:category category:travel group-currency:USD hello'); + + if (!defaultQueryJSON || !currentQueryJSON) { + throw new Error('Failed to parse query string'); + } + + expect(hasFiltersChangedFromDefault(currentQueryJSON, defaultQueryJSON)).toBe(false); + }); + + it('returns true when the current query has a different filter value', () => { + const defaultQueryJSON = buildSearchQueryJSON('type:expense category:travel'); + const currentQueryJSON = buildSearchQueryJSON('type:expense category:food'); + + if (!defaultQueryJSON || !currentQueryJSON) { + throw new Error('Failed to parse query string'); + } + + expect(hasFiltersChangedFromDefault(currentQueryJSON, defaultQueryJSON)).toBe(true); + }); + + it('returns true when the current query has an extra filter', () => { + const defaultQueryJSON = buildSearchQueryJSON('type:expense category:travel'); + const currentQueryJSON = buildSearchQueryJSON('type:expense category:travel merchant:Amazon'); + + if (!defaultQueryJSON || !currentQueryJSON) { + throw new Error('Failed to parse query string'); + } + + expect(hasFiltersChangedFromDefault(currentQueryJSON, defaultQueryJSON)).toBe(true); + }); + }); + describe('limit filter parsing', () => { it('parses limit value as a number', () => { const queryJSON = buildSearchQueryJSON('type:expense limit:25'); @@ -3684,76 +3874,6 @@ describe('SearchQueryUtils', () => { }); }); - describe('getAdvancedFiltersToReset', () => { - it('should return an empty object when input is empty', () => { - const result = getAdvancedFiltersToReset({}); - expect(result).toEqual({}); - }); - - it('should reset type to EXPENSE when it has a non-EXPENSE value', () => { - const form: Partial = { - type: CONST.SEARCH.DATA_TYPES.CHAT, - }; - const result = getAdvancedFiltersToReset(form); - expect(result).toEqual({ - type: CONST.SEARCH.DATA_TYPES.EXPENSE, - }); - }); - - it('should not include type in reset when it is already EXPENSE', () => { - const form: Partial = { - type: CONST.SEARCH.DATA_TYPES.EXPENSE, - }; - const result = getAdvancedFiltersToReset(form); - expect(result.type).toBeUndefined(); - }); - - it('should reset other filter keys to undefined', () => { - const form: Partial = { - merchant: 'Marriott', - currency: ['USD', 'EUR'], - dateAfter: '2024-01-01', - keyword: 'hotel', - status: [CONST.SEARCH.STATUS.EXPENSE.DRAFTS], - }; - const result = getAdvancedFiltersToReset(form); - expect(result).toEqual({ - merchant: undefined, - currency: undefined, - dateAfter: undefined, - keyword: undefined, - }); - }); - - it('should exclude columns from being reset if type is expense', () => { - const form: Partial = { - type: CONST.SEARCH.DATA_TYPES.EXPENSE, - columns: [CONST.SEARCH.TYPE_CUSTOM_COLUMNS.EXPENSE.DATE, CONST.SEARCH.TYPE_CUSTOM_COLUMNS.EXPENSE.MERCHANT], - merchant: 'test', - }; - const result = getAdvancedFiltersToReset(form); - expect(result.columns).toBeUndefined(); - expect(result).toEqual({ - merchant: undefined, - }); - }); - - it('should exclude columns from being reset', () => { - const form: Partial = { - type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, - columns: [CONST.SEARCH.TYPE_CUSTOM_COLUMNS.EXPENSE.DATE, CONST.SEARCH.TYPE_CUSTOM_COLUMNS.EXPENSE.MERCHANT], - merchant: 'test', - }; - const result = getAdvancedFiltersToReset(form); - expect(result.columns).toBeUndefined(); - expect(result).toEqual({ - type: CONST.SEARCH.DATA_TYPES.EXPENSE, - merchant: undefined, - columns: undefined, - }); - }); - }); - describe('isNegated', () => { it('returns true for negated filter keys (ending with the NOT modifier)', () => { expect(isFilterNegated(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT}${CONST.SEARCH.NOT_MODIFIER}`)).toBe(true); @@ -4126,6 +4246,84 @@ describe('SearchQueryUtils', () => { }); }); + describe('getValidLastQuery', () => { + const defaultSearchQuery = 'type:expense status:all'; + + it('returns the default query when the last query is undefined', () => { + expect(getValidLastQuery(undefined, defaultSearchQuery)).toBe(defaultSearchQuery); + }); + + it('returns the default query when the last query is an empty string', () => { + expect(getValidLastQuery('', defaultSearchQuery)).toBe(defaultSearchQuery); + }); + + it('returns the default query when the last query cannot be parsed', () => { + expect(getValidLastQuery('type:', defaultSearchQuery)).toBe(defaultSearchQuery); + }); + + it('returns the last query when it contains all default filter keys and matches the type', () => { + const lastQuery = 'type:expense status:all merchant:Amazon'; + expect(getValidLastQuery(lastQuery, defaultSearchQuery)).toBe(lastQuery); + }); + + it('returns the last query when it is identical to the default query', () => { + expect(getValidLastQuery(defaultSearchQuery, defaultSearchQuery)).toBe(defaultSearchQuery); + }); + + it('returns the default query when the last query is missing a default filter key', () => { + const lastQuery = 'type:expense'; + expect(getValidLastQuery(lastQuery, defaultSearchQuery)).toBe(defaultSearchQuery); + }); + + it('returns the default query when the last query type differs from the default type', () => { + const lastQuery = 'type:invoice status:all'; + expect(getValidLastQuery(lastQuery, defaultSearchQuery)).toBe(defaultSearchQuery); + }); + }); + + describe('doesQueryMatchDefaultFilterKeysAndType', () => { + const defaultQueryJSON = buildSearchQueryJSON('type:expense status:all merchant:Amazon'); + + it('returns true when the query has all default filter keys and the same type', () => { + const queryJSON = buildSearchQueryJSON('type:expense status:all merchant:Amazon category:travel'); + expect(doesQueryMatchDefaultFilterKeysAndType(queryJSON, defaultQueryJSON)).toBe(true); + }); + + it('returns true when the query is identical to the default query', () => { + const queryJSON = buildSearchQueryJSON('type:expense status:all merchant:Amazon'); + expect(doesQueryMatchDefaultFilterKeysAndType(queryJSON, defaultQueryJSON)).toBe(true); + }); + + it('returns false when the query is missing a default filter key', () => { + const queryJSON = buildSearchQueryJSON('type:expense status:all'); + expect(doesQueryMatchDefaultFilterKeysAndType(queryJSON, defaultQueryJSON)).toBe(false); + }); + + it('returns false when the query type differs from the default type', () => { + const queryJSON = buildSearchQueryJSON('type:invoice status:all merchant:Amazon'); + expect(doesQueryMatchDefaultFilterKeysAndType(queryJSON, defaultQueryJSON)).toBe(false); + }); + + it('returns true when the default query has no extra filter keys to satisfy', () => { + const queryJSON = buildSearchQueryJSON('type:expense status:all merchant:Amazon'); + const bareDefaultQueryJSON = buildSearchQueryJSON('type:expense'); + expect(doesQueryMatchDefaultFilterKeysAndType(queryJSON, bareDefaultQueryJSON)).toBe(true); + }); + + it('returns true when both query and default query are undefined', () => { + expect(doesQueryMatchDefaultFilterKeysAndType(undefined, undefined)).toBe(true); + }); + + it('returns true when the query is undefined since there is nothing to compare', () => { + expect(doesQueryMatchDefaultFilterKeysAndType(undefined, defaultQueryJSON)).toBe(true); + }); + + it('returns true when the default query is undefined since there are no default filter keys to enforce', () => { + const queryJSON = buildSearchQueryJSON('type:invoice'); + expect(doesQueryMatchDefaultFilterKeysAndType(queryJSON, undefined)).toBe(true); + }); + }); + describe('getDateFilterRange', () => { test('returns start and end for an on-date filter', () => { const queryJSON = buildSearchQueryJSON('type:expense date:2025-03-15'); diff --git a/tests/unit/Search/SearchSelectionProviderTest.tsx b/tests/unit/Search/SearchSelectionProviderTest.tsx index dfad6d4729cf..e8c3af31ea2e 100644 --- a/tests/unit/Search/SearchSelectionProviderTest.tsx +++ b/tests/unit/Search/SearchSelectionProviderTest.tsx @@ -25,6 +25,8 @@ const queryContextValue: SearchQueryContextValue = { currentSimilarSearchHash: 1, currentSearchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, currentSearchQueryJSON: expenseQueryJSON, + currentDefaultSearchQueryJSON: undefined, + currentDefaultSearchQueryFilterKeys: new Set(), suggestedSearches: getEmptyObject(), shouldResetSearchQuery: false, }; diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index ff9b66f284f5..c62ae945399a 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -17,7 +17,7 @@ import type { } from '@components/Search/SearchList/ListItem/types'; import {GROUP_ITEM_TYPES} from '@components/Search/SearchList/ListItem/types'; import {getExpenseHeaders} from '@components/Search/SearchTableHeader'; -import type {SearchColumnType, SelectedTransactionInfo, SortOrder} from '@components/Search/types'; +import type {SearchColumnType, SearchFilterKey, SelectedTransactionInfo, SortOrder} from '@components/Search/types'; import Navigation from '@navigation/Navigation'; @@ -13292,6 +13292,53 @@ describe('SearchUIUtils', () => { expect(SearchUIUtils.isTextFilterKey('')).toBe(false); }); }); + + describe('searchKeyToSavedSearchID', () => { + it('strips the prefix to recover the saved search ID', () => { + expect(SearchUIUtils.searchKeyToSavedSearchID(`${CONST.SEARCH.SAVED_SEARCH_PREFIX}12345`)).toBe('12345'); + }); + + it('returns undefined for a non saved-search key', () => { + expect(SearchUIUtils.searchKeyToSavedSearchID(CONST.SEARCH.SEARCH_KEYS.EXPENSES)).toBeUndefined(); + }); + + it('returns undefined when the key is undefined', () => { + expect(SearchUIUtils.searchKeyToSavedSearchID(undefined)).toBeUndefined(); + }); + }); + + describe('savedSearchIDToSearchKey', () => { + it('prefixes a saved search ID to build a search key', () => { + expect(SearchUIUtils.savedSearchIDToSearchKey('12345')).toBe(`${CONST.SEARCH.SAVED_SEARCH_PREFIX}12345`); + }); + }); + + describe('mapFiltersFormToLabelValueList', () => { + const convertToDisplayStringWithoutCurrency = jest.fn((amount = 0) => `${amount}`); + + it('places default filters before non-default filters', () => { + const form = { + [CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD]: 'hotel', + [CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]: 'Amazon', + }; + const defaultKeys = new Set([CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]); + + const result = SearchUIUtils.mapFiltersFormToLabelValueList( + form, + defaultKeys, + new Set(), + translateLocal, + undefined, + localeCompare, + convertToDisplayStringWithoutCurrency, + (filterKey, isDefault) => ({isDefault}), + ); + + expect(result.map((filter) => filter.key)).toEqual([CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD]); + expect(result.at(0)?.isDefault).toBe(true); + expect(result.at(1)?.isDefault).toBe(false); + }); + }); }); describe('getCardDescriptionForSearchTable', () => { @@ -13472,6 +13519,54 @@ describe('splitGroupsIntoPairs', () => { }); }); +describe('getLastSearchQuery', () => { + const submitKey = CONST.SEARCH.SEARCH_KEYS.SUBMIT; + const savedSearchKey = SearchUIUtils.savedSearchIDToSearchKey('100'); + const submitQuery = `type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} merchant:Zulu`; + const savedSearchQuery = `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Starbucks`; + + const searchFilters: OnyxTypes.SearchFilters = { + [submitKey]: {query: submitQuery, timestamp: '2026-08-21 00:00:00.000'}, + [savedSearchKey]: {query: savedSearchQuery, timestamp: '2026-08-21 00:00:00.000'}, + [CONST.SEARCH.SEARCH_KEYS.EXPENSES]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE}`, timestamp: '2026-08-21 00:00:00.000'}, + [CONST.SEARCH.SEARCH_KEYS.REPORTS]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT}`, timestamp: '2026-08-21 00:00:00.000'}, + [CONST.SEARCH.SEARCH_KEYS.EXPORT]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} exported:false`, timestamp: '2026-08-21 00:00:00.000'}, + [CONST.SEARCH.SEARCH_KEYS.STATEMENTS]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} feed:Expensify`, timestamp: '2026-08-21 00:00:00.000'}, + [CONST.SEARCH.SEARCH_KEYS.UNAPPROVED_CASH]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} reimbursable:true`, timestamp: '2026-08-21 00:00:00.000'}, + [CONST.SEARCH.SEARCH_KEYS.UNAPPROVED_CARD]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} reimbursable:false`, timestamp: '2026-08-21 00:00:00.000'}, + [CONST.SEARCH.SEARCH_KEYS.RECONCILIATION]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} posted:2026-08`, timestamp: '2026-08-21 00:00:00.000'}, + [CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} groupBy:from`, timestamp: '2026-08-21 00:00:00.000'}, + [CONST.SEARCH.SEARCH_KEYS.TOP_CATEGORIES]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} groupBy:category`, timestamp: '2026-08-21 00:00:00.000'}, + [CONST.SEARCH.SEARCH_KEYS.TOP_MERCHANTS]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} groupBy:merchant`, timestamp: '2026-08-21 00:00:00.000'}, + [CONST.SEARCH.SEARCH_KEYS.SPEND_OVER_TIME]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} groupBy:month`, timestamp: '2026-08-21 00:00:00.000'}, + [CONST.SEARCH.SEARCH_KEYS.VIOLATIONS_BY_SUBMITTER]: { + query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} groupBy:${CONST.SEARCH.GROUP_BY.FROM} has:${CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION}`, + timestamp: '2026-08-21 00:00:00.000', + }, + // Legacy string format, kept for the assertions below. + [CONST.SEARCH.SEARCH_KEYS.APPROVE]: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`, + [CONST.SEARCH.SEARCH_KEYS.PAY]: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Uber`, + }; + + it('returns the query of the filter stored for the search key', () => { + expect(SearchUIUtils.getLastSearchQuery(searchFilters, submitKey)).toBe(submitQuery); + expect(SearchUIUtils.getLastSearchQuery(searchFilters, savedSearchKey)).toBe(savedSearchQuery); + }); + + it('returns undefined for a filter stored in the legacy string format', () => { + expect(SearchUIUtils.getLastSearchQuery(searchFilters, CONST.SEARCH.SEARCH_KEYS.APPROVE)).toBeUndefined(); + expect(SearchUIUtils.getLastSearchQuery(searchFilters, CONST.SEARCH.SEARCH_KEYS.PAY)).toBeUndefined(); + }); + + it('returns undefined when the search key has no filter', () => { + expect(SearchUIUtils.getLastSearchQuery(searchFilters, SearchUIUtils.savedSearchIDToSearchKey('200'))).toBeUndefined(); + }); + + it('returns undefined when there are no filters at all', () => { + expect(SearchUIUtils.getLastSearchQuery(undefined, submitKey)).toBeUndefined(); + }); +}); + describe('getSavedSearchIconName', () => { it.each([ [CONST.SEARCH.DATA_TYPES.EXPENSE, 'type:expense', 'ReceiptBookmark'], diff --git a/tests/unit/SearchActionsTest.ts b/tests/unit/SearchActionsTest.ts index 3d3cc5897a42..e8625fcd6133 100644 --- a/tests/unit/SearchActionsTest.ts +++ b/tests/unit/SearchActionsTest.ts @@ -2,6 +2,7 @@ import type {LocalizedTranslate} from '@components/LocaleContextProvider'; import type {SelectedReports, SelectedTransactions} from '@components/Search/types'; import { + deleteSavedSearch, exportSearchItemsToCSV, getChatReportWithFallback, getExportTemplates, @@ -10,12 +11,15 @@ import { openSearch, queueExportSearchItemsToCSV, queueExportSearchWithTemplate, + saveSearch, + search, } from '@libs/actions/Search'; -import {read, write} from '@libs/API'; +import {makeRequestWithSideEffects, waitForWrites, read, write} from '@libs/API'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import fileDownload from '@libs/fileDownload'; import {translate} from '@libs/Localize'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import {savedSearchIDToSearchKey} from '@libs/SearchUIUtils'; import type {SearchKey} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; @@ -39,11 +43,11 @@ jest.mock('@libs/Network/enhanceParameters', () => ({ })); const mockWrite = jest.mocked(write); +const mockMakeRequestWithSideEffects = jest.mocked(makeRequestWithSideEffects); +const mockWaitForWrites = jest.mocked(waitForWrites); const mockFileDownload = jest.mocked(fileDownload); const mockRead = jest.mocked(read); -beforeEach(() => jest.clearAllMocks()); - function getWriteOptions(): {optimisticData: AnyOnyxUpdate[]; failureData: AnyOnyxUpdate[]} { const options = mockWrite.mock.calls.at(-1)?.at(2); if ( @@ -74,8 +78,8 @@ function getReadOptions(): {optimisticData: AnyOnyxUpdate[]; failureData: AnyOny return {optimisticData: options.optimisticData, failureData: options.failureData}; } -function getQueryJSON() { - const queryJSON = buildSearchQueryJSON(''); +function getQueryJSON(query = '') { + const queryJSON = buildSearchQueryJSON(query); if (!queryJSON) { throw new Error('Query JSON should be defined for test setup'); } @@ -83,84 +87,126 @@ function getQueryJSON() { return queryJSON; } -describe('openSearchPage', () => { - it('does not persist a completion flag that a failed request could strand', () => { - openSearch({includePartiallySetupBankAccounts: false, includeLockedBankAccounts: false}); +describe('SearchActions', () => { + beforeEach(() => jest.clearAllMocks()); - expect(mockRead).toHaveBeenCalledWith(READ_COMMANDS.OPEN_SEARCH_PAGE, { - includePartiallySetupBankAccounts: false, - includeLockedBankAccounts: false, + describe('saveSearch', () => { + const savedSearchID = '123456789'; + const queryJSON = getQueryJSON('type:expense status:all'); + + it('keys the optimistic, failure, and success data by the provided savedSearchID', () => { + saveSearch({id: savedSearchID, queryJSON, newName: 'My search'}); + + expect(mockWrite).toHaveBeenCalledWith( + WRITE_COMMANDS.SAVE_SEARCH, + {jsonQuery: JSON.stringify(queryJSON), savedSearchID, newName: 'My search'}, + { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SAVED_SEARCHES, + value: {[savedSearchID]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, name: 'My search', query: queryJSON.inputQuery}}, + }, + ], + failureData: [{onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.SAVED_SEARCHES, value: {[savedSearchID]: null}}], + successData: [{onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.SAVED_SEARCHES, value: {[savedSearchID]: {pendingAction: null}}}], + }, + ); }); - }); -}); -describe('queueExportSearchItemsToCSV', () => { - it('sets optimistic Onyx data with state preparing and returns exportID', () => { - const exportID = queueExportSearchItemsToCSV({ - jsonQuery: '{}', - reportIDList: [], - transactionIDList: [], - isBasicExport: true, - exportColumnLabels: '{}', - exportName: 'Basic export', - }); - - expect(typeof exportID).toBe('string'); - expect(exportID.length).toBeGreaterThan(0); - - expect(mockWrite).toHaveBeenCalledWith( - WRITE_COMMANDS.QUEUE_EXPORT_SEARCH_ITEMS_TO_CSV, - expect.objectContaining({exportID}), - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - expect.objectContaining({optimisticData: expect.any(Array), failureData: expect.any(Array)}), - ); - - const {optimisticData, failureData} = getWriteOptions(); - const exportDownloadUpdate = optimisticData.find((u) => u.key === `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}`); - expect(exportDownloadUpdate).toBeDefined(); - expect(exportDownloadUpdate?.value).toEqual({state: CONST.EXPORT_DOWNLOAD.STATE.PREPARING, exportType: CONST.EXPORT_DOWNLOAD.TYPE.CSV}); - - const failureUpdate = failureData.find((u) => u.key === `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}`); - expect(failureUpdate).toBeDefined(); - expect(failureUpdate?.value).toEqual({state: CONST.EXPORT_DOWNLOAD.STATE.FAILED, exportType: CONST.EXPORT_DOWNLOAD.TYPE.CSV}); + it('falls back to the input query for the name when no name is given', () => { + saveSearch({id: savedSearchID, queryJSON}); + + expect(mockWrite).toHaveBeenCalledWith( + WRITE_COMMANDS.SAVE_SEARCH, + {jsonQuery: JSON.stringify(queryJSON), savedSearchID, newName: queryJSON.inputQuery}, + expect.objectContaining({ + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SAVED_SEARCHES, + value: {[savedSearchID]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, name: queryJSON.inputQuery, query: queryJSON.inputQuery}}, + }, + ], + }), + ); + }); }); - it('includes excluded transaction IDs in the queued CSV payload', () => { - queueExportSearchItemsToCSV({ - jsonQuery: '{}', - reportIDList: [], - transactionIDList: ['tx1'], - excludedTransactionIDList: ['tx2'], - isBasicExport: true, - exportColumnLabels: '{}', - exportName: 'Basic export', + describe('deleteSavedSearch', () => { + it('sends the optimistic DELETE, failure revert, and success removal keyed by the savedSearchID', () => { + const savedSearchID = '987654321'; + deleteSavedSearch(savedSearchID); + + expect(mockWrite).toHaveBeenCalledWith( + WRITE_COMMANDS.DELETE_SAVED_SEARCH, + {savedSearchID}, + { + optimisticData: [{onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.SAVED_SEARCHES, value: {[savedSearchID]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}}}], + failureData: [{onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.SAVED_SEARCHES, value: {[savedSearchID]: {pendingAction: null}}}], + successData: [ + {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.SAVED_SEARCHES, value: {[savedSearchID]: null}}, + {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.SEARCH_FILTERS, value: {[savedSearchIDToSearchKey(savedSearchID)]: null}}, + ], + }, + ); }); - - expect(mockWrite).toHaveBeenCalledWith(WRITE_COMMANDS.QUEUE_EXPORT_SEARCH_ITEMS_TO_CSV, expect.objectContaining({excludedTransactionIDList: ['tx2']}), expect.any(Object)); }); - it('does not add an exclusion field when there are no exclusions', () => { - queueExportSearchItemsToCSV({ - jsonQuery: '{}', - reportIDList: [], - transactionIDList: ['tx1'], - isBasicExport: true, - exportColumnLabels: '{}', - exportName: 'Basic export', + describe('search', () => { + beforeEach(() => { + mockWaitForWrites.mockResolvedValue(undefined); }); - expect(mockWrite.mock.calls.at(-1)?.at(1)).not.toHaveProperty('excludedTransactionIDList'); - }); -}); + it('optimistically merges the current query into SEARCH_FILTERS for the search key', async () => { + const searchKey = CONST.SEARCH.SEARCH_KEYS.EXPENSES; + const queryJSON = getQueryJSON('type:expense status:all'); -describe('exportSearchItemsToCSV', () => { - beforeEach(() => jest.clearAllMocks()); + await search({queryJSON, searchKey, isLoading: false}); + + expect(mockMakeRequestWithSideEffects).toHaveBeenCalledWith( + READ_COMMANDS.SEARCH, + expect.anything(), + expect.objectContaining({ + optimisticData: expect.arrayContaining([{onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.SEARCH_FILTERS, value: {[searchKey]: {query: queryJSON.inputQuery}}}]), + }), + ); + }); + }); - it('includes excluded transaction IDs in the direct CSV form payload', () => { - const appendSpy = jest.spyOn(FormData.prototype, 'append'); + describe('queueExportSearchItemsToCSV', () => { + it('sets optimistic Onyx data with state preparing and returns exportID', () => { + const exportID = queueExportSearchItemsToCSV({ + jsonQuery: '{}', + reportIDList: [], + transactionIDList: [], + isBasicExport: true, + exportColumnLabels: '{}', + exportName: 'Basic export', + }); + + expect(typeof exportID).toBe('string'); + expect(exportID.length).toBeGreaterThan(0); + + expect(mockWrite).toHaveBeenCalledWith( + WRITE_COMMANDS.QUEUE_EXPORT_SEARCH_ITEMS_TO_CSV, + expect.objectContaining({exportID}), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect.objectContaining({optimisticData: expect.any(Array), failureData: expect.any(Array)}), + ); + + const {optimisticData, failureData} = getWriteOptions(); + const exportDownloadUpdate = optimisticData.find((u) => u.key === `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}`); + expect(exportDownloadUpdate).toBeDefined(); + expect(exportDownloadUpdate?.value).toEqual({state: CONST.EXPORT_DOWNLOAD.STATE.PREPARING, exportType: CONST.EXPORT_DOWNLOAD.TYPE.CSV}); + + const failureUpdate = failureData.find((u) => u.key === `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}`); + expect(failureUpdate).toBeDefined(); + expect(failureUpdate?.value).toEqual({state: CONST.EXPORT_DOWNLOAD.STATE.FAILED, exportType: CONST.EXPORT_DOWNLOAD.TYPE.CSV}); + }); - exportSearchItemsToCSV( - { + it('includes excluded transaction IDs in the queued CSV payload', () => { + queueExportSearchItemsToCSV({ jsonQuery: '{}', reportIDList: [], transactionIDList: ['tx1'], @@ -168,21 +214,62 @@ describe('exportSearchItemsToCSV', () => { isBasicExport: true, exportColumnLabels: '{}', exportName: 'Basic export', - }, - jest.fn(), - translateForTest, - ); - - expect(appendSpy).toHaveBeenCalledWith('excludedTransactionIDList', 'tx2'); - expect(mockFileDownload).toHaveBeenCalled(); - appendSpy.mockRestore(); + }); + + expect(mockWrite).toHaveBeenCalledWith(WRITE_COMMANDS.QUEUE_EXPORT_SEARCH_ITEMS_TO_CSV, expect.objectContaining({excludedTransactionIDList: ['tx2']}), expect.any(Object)); + }); + + it('does not add an exclusion field when there are no exclusions', () => { + queueExportSearchItemsToCSV({ + jsonQuery: '{}', + reportIDList: [], + transactionIDList: ['tx1'], + isBasicExport: true, + exportColumnLabels: '{}', + exportName: 'Basic export', + }); + + expect(mockWrite.mock.calls.at(-1)?.at(1)).not.toHaveProperty('excludedTransactionIDList'); + }); }); -}); -describe('queueExportSearchWithTemplate', () => { - it('sets optimistic Onyx data with state preparing and returns exportID when tracking progress', () => { - const exportID = queueExportSearchWithTemplate( - { + describe('queueExportSearchWithTemplate', () => { + it('sets optimistic Onyx data with state preparing and returns exportID when tracking progress', () => { + const exportID = queueExportSearchWithTemplate( + { + templateName: 'Test Template', + templateType: 'csv', + jsonQuery: '{}', + reportIDList: [], + transactionIDList: [], + policyID: 'policy123', + exportName: 'Test Template', + }, + true, + ); + + expect(typeof exportID).toBe('string'); + expect(exportID.length).toBeGreaterThan(0); + + expect(mockWrite).toHaveBeenCalledWith( + WRITE_COMMANDS.QUEUE_EXPORT_SEARCH_WITH_TEMPLATE, + expect.objectContaining({exportID}), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect.objectContaining({optimisticData: expect.any(Array), failureData: expect.any(Array)}), + ); + + const {optimisticData, failureData} = getWriteOptions(); + const exportDownloadUpdate = optimisticData.find((u) => u.key === `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}`); + expect(exportDownloadUpdate).toBeDefined(); + expect(exportDownloadUpdate?.value).toEqual({state: CONST.EXPORT_DOWNLOAD.STATE.PREPARING, exportType: CONST.EXPORT_DOWNLOAD.TYPE.CSV}); + + const failureUpdate = failureData.find((u) => u.key === `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}`); + expect(failureUpdate).toBeDefined(); + expect(failureUpdate?.value).toEqual({state: CONST.EXPORT_DOWNLOAD.STATE.FAILED, exportType: CONST.EXPORT_DOWNLOAD.TYPE.CSV}); + }); + + it('keeps the legacy request shape (no exportID, no optimistic data) when not tracking progress', () => { + queueExportSearchWithTemplate({ templateName: 'Test Template', templateType: 'csv', jsonQuery: '{}', @@ -190,246 +277,261 @@ describe('queueExportSearchWithTemplate', () => { transactionIDList: [], policyID: 'policy123', exportName: 'Test Template', - }, - true, - ); - - expect(typeof exportID).toBe('string'); - expect(exportID.length).toBeGreaterThan(0); - - expect(mockWrite).toHaveBeenCalledWith( - WRITE_COMMANDS.QUEUE_EXPORT_SEARCH_WITH_TEMPLATE, - expect.objectContaining({exportID}), - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - expect.objectContaining({optimisticData: expect.any(Array), failureData: expect.any(Array)}), - ); - - const {optimisticData, failureData} = getWriteOptions(); - const exportDownloadUpdate = optimisticData.find((u) => u.key === `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}`); - expect(exportDownloadUpdate).toBeDefined(); - expect(exportDownloadUpdate?.value).toEqual({state: CONST.EXPORT_DOWNLOAD.STATE.PREPARING, exportType: CONST.EXPORT_DOWNLOAD.TYPE.CSV}); - - const failureUpdate = failureData.find((u) => u.key === `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}`); - expect(failureUpdate).toBeDefined(); - expect(failureUpdate?.value).toEqual({state: CONST.EXPORT_DOWNLOAD.STATE.FAILED, exportType: CONST.EXPORT_DOWNLOAD.TYPE.CSV}); - }); + }); - it('keeps the legacy request shape (no exportID, no optimistic data) when not tracking progress', () => { - queueExportSearchWithTemplate({ - templateName: 'Test Template', - templateType: 'csv', - jsonQuery: '{}', - reportIDList: [], - transactionIDList: [], - policyID: 'policy123', - exportName: 'Test Template', - }); + const finalParameters = mockWrite.mock.calls.at(-1)?.at(1); + expect(finalParameters).not.toHaveProperty('exportID'); - const finalParameters = mockWrite.mock.calls.at(-1)?.at(1); - expect(finalParameters).not.toHaveProperty('exportID'); + const options = mockWrite.mock.calls.at(-1)?.at(2); + expect(options).toEqual({}); + }); + }); - const options = mockWrite.mock.calls.at(-1)?.at(2); - expect(options).toEqual({}); + describe('exportSearchItemsToCSV', () => { + beforeEach(() => jest.clearAllMocks()); + + it('includes excluded transaction IDs in the direct CSV form payload', () => { + const appendSpy = jest.spyOn(FormData.prototype, 'append'); + + exportSearchItemsToCSV( + { + jsonQuery: '{}', + reportIDList: [], + transactionIDList: ['tx1'], + excludedTransactionIDList: ['tx2'], + isBasicExport: true, + exportColumnLabels: '{}', + exportName: 'Basic export', + }, + jest.fn(), + translateForTest, + ); + + expect(appendSpy).toHaveBeenCalledWith('excludedTransactionIDList', 'tx2'); + expect(mockFileDownload).toHaveBeenCalled(); + appendSpy.mockRestore(); + }); }); -}); -describe('getFooterConvertedAmounts', () => { - beforeEach(() => jest.clearAllMocks()); + describe('getExportTemplates', () => { + const translateForTemplates = translateLocal; + const localeCompare = (first: string, second: string) => first.localeCompare(second); + const makeTemplate = (name: string): ExportTemplate => ({name, templateName: name, type: '', policyID: undefined, description: ''}); + const makePolicyWithOutputCurrency = (outputCurrency: string): Policy => ({...createRandomPolicy(1), outputCurrency}); - it('does not call API.read when the target currency is empty', () => { - getFooterConvertedAmounts({queryJSON: getQueryJSON(), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES as SearchKey, targetCurrency: ''}); + it('returns the custom templates and the default templates as separate groups, each sorted alphabetically', () => { + const integrationsExportTemplates: ExportTemplate[] = [makeTemplate('Zebra integration'), makeTemplate('Apple integration')]; + const csvExportLayouts: Record = { + mango: makeTemplate('Mango layout'), + banana: makeTemplate('Banana layout'), + }; - expect(mockRead).not.toHaveBeenCalled(); - }); + const {customTemplates, defaultTemplates} = getExportTemplates(integrationsExportTemplates, csvExportLayouts, translateForTemplates, localeCompare); - it('requests the whole-search conversion when no transaction or report IDs are given', () => { - getFooterConvertedAmounts({queryJSON: getQueryJSON(), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES as SearchKey, targetCurrency: 'EUR'}); + // Custom group (custom integrations + in-app templates) is sorted alphabetically + expect(customTemplates.map((template) => template.name)).toEqual(['Apple integration', 'Banana layout', 'Mango layout', 'Zebra integration']); - expect(mockRead).toHaveBeenCalledWith( - READ_COMMANDS.GET_TRANSACTIONS_CONVERTED_AMOUNT, - expect.objectContaining({targetCurrency: 'EUR'}), - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - expect.objectContaining({optimisticData: expect.any(Array), failureData: expect.any(Array)}), - ); + // Default group (expense/report level) is sorted alphabetically + expect(defaultTemplates.map((template) => template.name)).toEqual([translateForTemplates('export.expenseLevelExport'), translateForTemplates('export.reportLevelExport')]); + }); - const params = mockRead.mock.calls.at(-1)?.at(1); - expect(params).not.toHaveProperty('transactionIDList'); - expect(params).not.toHaveProperty('reportIDList'); - }); + it('excludes the report level export template when includeReportLevelExport is false', () => { + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, undefined, false); + const templateNames = defaultTemplates.map((template) => template.templateName); - it('scopes the request to the given transaction and report IDs', () => { - getFooterConvertedAmounts({ - queryJSON: getQueryJSON(), - searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES as SearchKey, - targetCurrency: 'EUR', - transactionIDList: '1,2', - reportIDList: '3,4', + expect(templateNames).toContain(CONST.REPORT.EXPORT_OPTIONS.EXPENSE_LEVEL_EXPORT); + expect(templateNames).not.toContain(CONST.REPORT.EXPORT_OPTIONS.REPORT_LEVEL_EXPORT); }); - expect(mockRead).toHaveBeenCalledWith(READ_COMMANDS.GET_TRANSACTIONS_CONVERTED_AMOUNT, expect.objectContaining({transactionIDList: '1,2', reportIDList: '3,4'}), expect.anything()); - }); + it('excludes the basic export template by default', () => { + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare); + const templateNames = defaultTemplates.map((template) => template.templateName); - it('optimistically stamps the sources and clears any prior failure for the target currency', () => { - const sources = {transactions: {transaction1: {USD: 42.5}}}; + expect(templateNames).not.toContain(CONST.REPORT.EXPORT_OPTIONS.DOWNLOAD_CSV); + }); - getFooterConvertedAmounts({queryJSON: getQueryJSON(), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES as SearchKey, targetCurrency: 'EUR', sources}); + it('includes the basic export template in the default group (sorted alphabetically) when includeBasicExport is true', () => { + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, undefined, true, true); + const names = defaultTemplates.map((template) => template.name); - const {optimisticData} = getReadOptions(); - const conversionUpdate = optimisticData.find((update) => update.key === ONYXKEYS.SEARCH_FOOTER_CONVERSION); - expect(conversionUpdate).toBeDefined(); - expect(conversionUpdate?.value).toEqual({sources, failedCurrencies: {EUR: null}}); - }); + // Basic export is sorted alphabetically alongside the other default templates, not pinned to the bottom + expect(names).toEqual( + [translateForTemplates('export.expenseLevelExport'), translateForTemplates('export.reportLevelExport'), translateForTemplates('export.basicExport')].sort(localeCompare), + ); + }); - it('marks the target currency as failed on failureData', () => { - getFooterConvertedAmounts({queryJSON: getQueryJSON(), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES as SearchKey, targetCurrency: 'EUR'}); + it('includes the Canadian Multiple Tax Export template when the policy outputs in CAD', () => { + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, makePolicyWithOutputCurrency(CONST.CURRENCY.CAD)); - const {failureData} = getReadOptions(); - const conversionUpdate = failureData.find((update) => update.key === ONYXKEYS.SEARCH_FOOTER_CONVERSION); - expect(conversionUpdate).toBeDefined(); - expect(conversionUpdate?.value).toEqual({failedCurrencies: {EUR: true}}); - }); -}); + expect(defaultTemplates.map((template) => template.templateName)).toContain(CONST.REPORT.EXPORT_OPTIONS.MULTIPLE_TAX_EXPORT); + }); -describe('getExportTemplates', () => { - const translateForTemplates = translateLocal; - const localeCompare = (first: string, second: string) => first.localeCompare(second); - const makeTemplate = (name: string): ExportTemplate => ({name, templateName: name, type: '', policyID: undefined, description: ''}); - const makePolicyWithOutputCurrency = (outputCurrency: string): Policy => ({...createRandomPolicy(1), outputCurrency}); - - it('returns the custom templates and the default templates as separate groups, each sorted alphabetically', () => { - const integrationsExportTemplates: ExportTemplate[] = [makeTemplate('Zebra integration'), makeTemplate('Apple integration')]; - const csvExportLayouts: Record = { - mango: makeTemplate('Mango layout'), - banana: makeTemplate('Banana layout'), - }; + it('excludes the Canadian Multiple Tax Export template when the policy outputs in another currency', () => { + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, makePolicyWithOutputCurrency(CONST.CURRENCY.USD)); - const {customTemplates, defaultTemplates} = getExportTemplates(integrationsExportTemplates, csvExportLayouts, translateForTemplates, localeCompare); + expect(defaultTemplates.map((template) => template.templateName)).not.toContain(CONST.REPORT.EXPORT_OPTIONS.MULTIPLE_TAX_EXPORT); + }); - // Custom group (custom integrations + in-app templates) is sorted alphabetically - expect(customTemplates.map((template) => template.name)).toEqual(['Apple integration', 'Banana layout', 'Mango layout', 'Zebra integration']); + it('includes the Canadian Multiple Tax Export template when includeMultipleTaxExport is true without a policy', () => { + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, undefined, true, false, true); - // Default group (expense/report level) is sorted alphabetically - expect(defaultTemplates.map((template) => template.name)).toEqual([translateForTemplates('export.expenseLevelExport'), translateForTemplates('export.reportLevelExport')]); - }); + expect(defaultTemplates.map((template) => template.templateName)).toContain(CONST.REPORT.EXPORT_OPTIONS.MULTIPLE_TAX_EXPORT); + }); - it('excludes the report level export template when includeReportLevelExport is false', () => { - const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, undefined, false); - const templateNames = defaultTemplates.map((template) => template.templateName); + it('excludes the Canadian Multiple Tax Export template when includeMultipleTaxExport is false for a CAD policy', () => { + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, makePolicyWithOutputCurrency(CONST.CURRENCY.CAD), true, false, false); - expect(templateNames).toContain(CONST.REPORT.EXPORT_OPTIONS.EXPENSE_LEVEL_EXPORT); - expect(templateNames).not.toContain(CONST.REPORT.EXPORT_OPTIONS.REPORT_LEVEL_EXPORT); - }); + expect(defaultTemplates.map((template) => template.templateName)).not.toContain(CONST.REPORT.EXPORT_OPTIONS.MULTIPLE_TAX_EXPORT); + }); - it('excludes the basic export template by default', () => { - const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare); - const templateNames = defaultTemplates.map((template) => template.templateName); + it('includes the Reconciliation - All Expenses template when the user is a workspace admin of a policy with company cards enabled', () => { + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, { + ...createRandomPolicy(1), + role: CONST.POLICY.ROLE.ADMIN, + areCompanyCardsEnabled: true, + }); - expect(templateNames).not.toContain(CONST.REPORT.EXPORT_OPTIONS.DOWNLOAD_CSV); - }); + expect(defaultTemplates.map((template) => template.templateName)).toContain(CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES); + }); - it('includes the basic export template in the default group (sorted alphabetically) when includeBasicExport is true', () => { - const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, undefined, true, true); - const names = defaultTemplates.map((template) => template.name); + it('includes the Reconciliation - All Expenses template when the user is a card admin of a policy with the Expensify Card enabled', () => { + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, { + ...createRandomPolicy(1), + role: CONST.POLICY.ROLE.CARD_ADMIN, + areExpensifyCardsEnabled: true, + }); - // Basic export is sorted alphabetically alongside the other default templates, not pinned to the bottom - expect(names).toEqual( - [translateForTemplates('export.expenseLevelExport'), translateForTemplates('export.reportLevelExport'), translateForTemplates('export.basicExport')].sort(localeCompare), - ); - }); + expect(defaultTemplates.map((template) => template.templateName)).toContain(CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES); + }); - it('includes the Canadian Multiple Tax Export template when the policy outputs in CAD', () => { - const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, makePolicyWithOutputCurrency(CONST.CURRENCY.CAD)); + it('excludes the Reconciliation - All Expenses template when the user is a workspace member', () => { + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, { + ...createRandomPolicy(1), + role: CONST.POLICY.ROLE.USER, + areCompanyCardsEnabled: true, + }); - expect(defaultTemplates.map((template) => template.templateName)).toContain(CONST.REPORT.EXPORT_OPTIONS.MULTIPLE_TAX_EXPORT); - }); + expect(defaultTemplates.map((template) => template.templateName)).not.toContain(CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES); + }); - it('excludes the Canadian Multiple Tax Export template when the policy outputs in another currency', () => { - const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, makePolicyWithOutputCurrency(CONST.CURRENCY.USD)); + it('excludes the Reconciliation - All Expenses template when the admin policy has no card product enabled', () => { + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, { + ...createRandomPolicy(1), + role: CONST.POLICY.ROLE.ADMIN, + areCompanyCardsEnabled: false, + areExpensifyCardsEnabled: false, + }); - expect(defaultTemplates.map((template) => template.templateName)).not.toContain(CONST.REPORT.EXPORT_OPTIONS.MULTIPLE_TAX_EXPORT); - }); + expect(defaultTemplates.map((template) => template.templateName)).not.toContain(CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES); + }); - it('includes the Canadian Multiple Tax Export template when includeMultipleTaxExport is true without a policy', () => { - const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, undefined, true, false, true); + it('includes the Reconciliation - All Expenses template when includeReconciliationAllExpenses is true without a policy', () => { + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, undefined, true, false, false, true); - expect(defaultTemplates.map((template) => template.templateName)).toContain(CONST.REPORT.EXPORT_OPTIONS.MULTIPLE_TAX_EXPORT); + expect(defaultTemplates.map((template) => template.templateName)).toContain(CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES); + }); + + it('excludes the Reconciliation - All Expenses template when includeReconciliationAllExpenses is false for an admin policy', () => { + const {defaultTemplates} = getExportTemplates( + [], + {}, + translateForTemplates, + localeCompare, + {...createRandomPolicy(1), role: CONST.POLICY.ROLE.ADMIN, areCompanyCardsEnabled: true}, + true, + false, + false, + false, + ); + + expect(defaultTemplates.map((template) => template.templateName)).not.toContain(CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES); + }); }); - it('excludes the Canadian Multiple Tax Export template when includeMultipleTaxExport is false for a CAD policy', () => { - const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, makePolicyWithOutputCurrency(CONST.CURRENCY.CAD), true, false, false); + describe('getFooterConvertedAmounts', () => { + it('does not call API.read when the target currency is empty', () => { + getFooterConvertedAmounts({queryJSON: getQueryJSON(), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES as SearchKey, targetCurrency: ''}); - expect(defaultTemplates.map((template) => template.templateName)).not.toContain(CONST.REPORT.EXPORT_OPTIONS.MULTIPLE_TAX_EXPORT); - }); + expect(mockRead).not.toHaveBeenCalled(); + }); - it('includes the Reconciliation - All Expenses template when the user is a workspace admin of a policy with company cards enabled', () => { - const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, {...createRandomPolicy(1), role: CONST.POLICY.ROLE.ADMIN, areCompanyCardsEnabled: true}); + it('requests the whole-search conversion when no transaction or report IDs are given', () => { + getFooterConvertedAmounts({queryJSON: getQueryJSON(), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES as SearchKey, targetCurrency: 'EUR'}); - expect(defaultTemplates.map((template) => template.templateName)).toContain(CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES); - }); + expect(mockRead).toHaveBeenCalledWith( + READ_COMMANDS.GET_TRANSACTIONS_CONVERTED_AMOUNT, + expect.objectContaining({targetCurrency: 'EUR'}), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect.objectContaining({optimisticData: expect.any(Array), failureData: expect.any(Array)}), + ); - it('includes the Reconciliation - All Expenses template when the user is a card admin of a policy with the Expensify Card enabled', () => { - const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, { - ...createRandomPolicy(1), - role: CONST.POLICY.ROLE.CARD_ADMIN, - areExpensifyCardsEnabled: true, + const params = mockRead.mock.calls.at(-1)?.at(1); + expect(params).not.toHaveProperty('transactionIDList'); + expect(params).not.toHaveProperty('reportIDList'); }); - expect(defaultTemplates.map((template) => template.templateName)).toContain(CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES); - }); + it('scopes the request to the given transaction and report IDs', () => { + getFooterConvertedAmounts({ + queryJSON: getQueryJSON(), + searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES as SearchKey, + targetCurrency: 'EUR', + transactionIDList: '1,2', + reportIDList: '3,4', + }); + + expect(mockRead).toHaveBeenCalledWith( + READ_COMMANDS.GET_TRANSACTIONS_CONVERTED_AMOUNT, + expect.objectContaining({transactionIDList: '1,2', reportIDList: '3,4'}), + expect.anything(), + ); + }); - it('excludes the Reconciliation - All Expenses template when the user is a workspace member', () => { - const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, {...createRandomPolicy(1), role: CONST.POLICY.ROLE.USER, areCompanyCardsEnabled: true}); + it('optimistically stamps the sources and clears any prior failure for the target currency', () => { + const sources = {transactions: {transaction1: {USD: 42.5}}}; - expect(defaultTemplates.map((template) => template.templateName)).not.toContain(CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES); - }); + getFooterConvertedAmounts({queryJSON: getQueryJSON(), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES as SearchKey, targetCurrency: 'EUR', sources}); - it('excludes the Reconciliation - All Expenses template when the admin policy has no card product enabled', () => { - const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, { - ...createRandomPolicy(1), - role: CONST.POLICY.ROLE.ADMIN, - areCompanyCardsEnabled: false, - areExpensifyCardsEnabled: false, + const {optimisticData} = getReadOptions(); + const conversionUpdate = optimisticData.find((update) => update.key === ONYXKEYS.SEARCH_FOOTER_CONVERSION); + expect(conversionUpdate).toBeDefined(); + expect(conversionUpdate?.value).toEqual({sources, failedCurrencies: {EUR: null}}); }); - expect(defaultTemplates.map((template) => template.templateName)).not.toContain(CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES); - }); - - it('includes the Reconciliation - All Expenses template when includeReconciliationAllExpenses is true without a policy', () => { - const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, undefined, true, false, false, true); + it('marks the target currency as failed on failureData', () => { + getFooterConvertedAmounts({queryJSON: getQueryJSON(), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES as SearchKey, targetCurrency: 'EUR'}); - expect(defaultTemplates.map((template) => template.templateName)).toContain(CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES); + const {failureData} = getReadOptions(); + const conversionUpdate = failureData.find((update) => update.key === ONYXKEYS.SEARCH_FOOTER_CONVERSION); + expect(conversionUpdate).toBeDefined(); + expect(conversionUpdate?.value).toEqual({failedCurrencies: {EUR: true}}); + }); }); - it('excludes the Reconciliation - All Expenses template when includeReconciliationAllExpenses is false for an admin policy', () => { - const {defaultTemplates} = getExportTemplates( - [], - {}, - translateForTemplates, - localeCompare, - {...createRandomPolicy(1), role: CONST.POLICY.ROLE.ADMIN, areCompanyCardsEnabled: true}, - true, - false, - false, - false, - ); - - expect(defaultTemplates.map((template) => template.templateName)).not.toContain(CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES); + describe('openSearchPage', () => { + it('does not persist a completion flag that a failed request could strand', () => { + openSearch({includePartiallySetupBankAccounts: false, includeLockedBankAccounts: false}); + + expect(mockRead).toHaveBeenCalledWith(READ_COMMANDS.OPEN_SEARCH_PAGE, { + includePartiallySetupBankAccounts: false, + includeLockedBankAccounts: false, + }); + }); }); -}); -describe('getChatReportWithFallback', () => { - const loadedChatReport = {reportID: 'chat1', policyID: 'policyA', type: CONST.REPORT.TYPE.CHAT} as Report; + describe('getChatReportWithFallback', () => { + const loadedChatReport = {reportID: 'chat1', policyID: 'policyA', type: CONST.REPORT.TYPE.CHAT} as Report; - it('returns the loaded chat report when it is available', () => { - expect(getChatReportWithFallback(loadedChatReport, 'chat2', 'policyB')).toEqual({chatReport: loadedChatReport, isFallbackChatReport: false}); - }); + it('returns the loaded chat report when it is available', () => { + expect(getChatReportWithFallback(loadedChatReport, 'chat2', 'policyB')).toEqual({chatReport: loadedChatReport, isFallbackChatReport: false}); + }); - it('builds a fallback chat report from the known IDs when the chat is not loaded', () => { - expect(getChatReportWithFallback(undefined, 'chat2', 'policyB')).toEqual({chatReport: {reportID: 'chat2', policyID: 'policyB'}, isFallbackChatReport: true}); - }); + it('builds a fallback chat report from the known IDs when the chat is not loaded', () => { + expect(getChatReportWithFallback(undefined, 'chat2', 'policyB')).toEqual({chatReport: {reportID: 'chat2', policyID: 'policyB'}, isFallbackChatReport: true}); + }); - it('returns no chat report when the chat is not loaded and there is no fallback chatReportID', () => { - expect(getChatReportWithFallback(undefined, undefined, 'policyB')).toEqual({chatReport: undefined, isFallbackChatReport: false}); + it('returns no chat report when the chat is not loaded and there is no fallback chatReportID', () => { + expect(getChatReportWithFallback(undefined, undefined, 'policyB')).toEqual({chatReport: undefined, isFallbackChatReport: false}); + }); }); }); diff --git a/tests/unit/SearchRouterNavigationTest.ts b/tests/unit/SearchRouterNavigationTest.ts index ad91d038df50..75f0deb5f6e9 100644 --- a/tests/unit/SearchRouterNavigationTest.ts +++ b/tests/unit/SearchRouterNavigationTest.ts @@ -42,15 +42,9 @@ import {isValidElement} from 'react'; import createRandomPolicy from '../utils/collections/policies'; import createMock from '../utils/createMock'; -type MockSearchTypeMenuSectionsResult = { - typeMenuSections: SearchTypeMenuSection[]; - activeItemIndex: number; - activeKey: string | undefined; -}; - type GetWorkspaceMenuItems = typeof getWorkspaceMenuItems; -const mockUseSearchTypeMenuSections = jest.fn(); +const mockUseSearchTypeMenuSections = jest.fn(); const mockUseMemoizedLazyExpensifyIcons = jest.fn, []>(); const mockUseCreateNavigationSuggestions = jest.fn(() => []); const mockUseSettingsNavigationMenuData = jest.fn<{accountMenuItemsData: MenuSection; generalMenuItemsData: MenuSection}, []>(); @@ -63,6 +57,8 @@ const currentUserAccountID = 1; jest.mock('@components/Search/SearchContext', () => ({ useSearchSelectionActions: () => ({clearSelectedTransactions: mockClearSelectedTransactions}), + useSearchQueryContext: () => ({}), + useSearchQueryActions: () => ({}), })); jest.mock('@components/Search/SearchRouter/useCreateNavigationSuggestions', () => ({ @@ -556,7 +552,7 @@ describe('Domain Search Router navigation source', () => { Building: mockIcon, Gear: mockIcon, }); - mockUseSearchTypeMenuSections.mockReturnValue({typeMenuSections: [], activeItemIndex: -1, activeKey: undefined}); + mockUseSearchTypeMenuSections.mockReturnValue([]); const {result} = renderHook(() => useNavigationSuggestions('members')); @@ -745,16 +741,12 @@ describe('Workspace Search Router navigation source', () => { ReceiptMultiple: mockIcon, Gear: mockIcon, }); - mockUseSearchTypeMenuSections.mockReturnValue({ - typeMenuSections: [ - { - translationPath: 'search.tabs.expenseReports', - menuItems: [createSpendMenuItem(CONST.SEARCH.SEARCH_KEYS.REPORTS, 'search.tabs.reports', 'Document', 'type:expense-report')], - }, - ], - activeItemIndex: -1, - activeKey: undefined, - }); + mockUseSearchTypeMenuSections.mockReturnValue([ + { + translationPath: 'search.tabs.expenseReports', + menuItems: [createSpendMenuItem(CONST.SEARCH.SEARCH_KEYS.REPORTS, 'search.tabs.reports', 'Document', 'type:expense-report')], + }, + ]); mockUseSettingsNavigationMenuData.mockReturnValue({ accountMenuItemsData: { sectionTranslationKey: 'initialSettingsPage.account', @@ -837,7 +829,7 @@ describe('Spend Search Router navigation source', () => { expect(items.map((item) => item.matchTerms)).toEqual([['Reports'], ['Expenses']]); items.at(0)?.action?.(); - expect(onSelect).toHaveBeenCalledWith(reportsQuery); + expect(onSelect).toHaveBeenCalledWith(CONST.SEARCH.SEARCH_KEYS.REPORTS, reportsQuery); }); it('does not use the right-side Spend context as a matching term', () => { @@ -873,7 +865,7 @@ describe('Spend Search Router navigation source', () => { const clearSelectedTransactions = jest.fn(); const searchQuery = 'type:expense sortBy:date sortOrder:desc'; - navigateToCannedSpendSearch(searchQuery, clearSelectedTransactions); + navigateToCannedSpendSearch(CONST.SEARCH.SEARCH_KEYS.EXPENSES, searchQuery, undefined, clearSelectedTransactions, jest.fn()); expect(clearSelectedTransactions).toHaveBeenCalledTimes(1); expect(setSearchContext).toHaveBeenCalledWith(false); @@ -882,6 +874,37 @@ describe('Spend Search Router navigation source', () => { expect(jest.mocked(setSearchContext).mock.invocationCallOrder.at(0)).toBeLessThan(jest.mocked(Navigation.navigate).mock.invocationCallOrder.at(0) ?? 0); }); + it('passes the query it navigates to as the search key target, so the update can be deferred until the query changes', () => { + const setCurrentSearchKey = jest.fn(); + const searchQuery = 'type:expense sortBy:date sortOrder:desc'; + // The last query stays valid for the default query, so it is the one we navigate to. + const lastSearchQuery = 'type:expense sortBy:date sortOrder:desc merchant:test'; + + navigateToCannedSpendSearch(CONST.SEARCH.SEARCH_KEYS.EXPENSES, searchQuery, lastSearchQuery, jest.fn(), setCurrentSearchKey); + + expect(setCurrentSearchKey).toHaveBeenCalledWith(CONST.SEARCH.SEARCH_KEYS.EXPENSES, lastSearchQuery); + }); + + it('navigates with the last query when it is still valid for the default query', () => { + const searchQuery = 'type:expense sortBy:date sortOrder:desc'; + // The last query adds a filter but keeps the default query's type and (empty) filter keys, so it stays valid. + const lastSearchQuery = 'type:expense sortBy:date sortOrder:desc merchant:test'; + + navigateToCannedSpendSearch(CONST.SEARCH.SEARCH_KEYS.EXPENSES, searchQuery, lastSearchQuery, jest.fn(), jest.fn()); + + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_ROOT.getRoute({query: lastSearchQuery})); + }); + + it('falls back to the default query when the last query drops one of its filters', () => { + const searchQuery = 'type:expense merchant:Amazon'; + // The last query drops the default's merchant filter, so it is no longer valid and the default is used. + const lastSearchQuery = 'type:expense category:Food'; + + navigateToCannedSpendSearch(CONST.SEARCH.SEARCH_KEYS.EXPENSES, searchQuery, lastSearchQuery, jest.fn(), jest.fn()); + + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_ROOT.getRoute({query: searchQuery})); + }); + it('composes Spend suggestions from the menu hook with icons, context, exclusions, and approval gating', () => { const reportsIcon: IconAsset = () => null; const spendContextIcon: IconAsset = () => null; @@ -894,26 +917,22 @@ describe('Spend Search Router navigation source', () => { Gear: mockIcon, Document: reportsIcon, }); - mockUseSearchTypeMenuSections.mockReturnValue({ - typeMenuSections: [ - { - translationPath: 'search.tabs.expenseReports', - menuItems: [createSpendMenuItem(CONST.SEARCH.SEARCH_KEYS.REPORTS, 'search.tabs.reports', 'Document', 'type:expense-report')], - }, - { - translationPath: 'search.savedSearchesMenuItemTitle', - menuItems: [createSpendMenuItem(`${CONST.SEARCH.SAVED_SEARCH_PREFIX}1`, 'search.tabs.reports', 'Receipt', 'saved-search-query')], - }, - ], - activeItemIndex: -1, - activeKey: undefined, - }); + mockUseSearchTypeMenuSections.mockReturnValue([ + { + translationPath: 'search.tabs.expenseReports', + menuItems: [createSpendMenuItem(CONST.SEARCH.SEARCH_KEYS.REPORTS, 'search.tabs.reports', 'Document', 'type:expense-report')], + }, + { + translationPath: 'search.savedSearchesMenuItemTitle', + menuItems: [createSpendMenuItem(`${CONST.SEARCH.SAVED_SEARCH_PREFIX}1`, 'search.tabs.reports', 'Receipt', 'saved-search-query')], + }, + ]); const {result, rerender} = renderHook(({shouldWatchForApprovals}) => useNavigationSuggestions('reports', shouldWatchForApprovals), { initialProps: {shouldWatchForApprovals: false}, }); - expect(mockUseSearchTypeMenuSections).toHaveBeenLastCalledWith(undefined, false); + expect(mockUseSearchTypeMenuSections).toHaveBeenLastCalledWith(false, undefined); expect(result.current).toHaveLength(1); expect(result.current.at(0)).toMatchObject({ text: 'Go to Reports', @@ -930,7 +949,7 @@ describe('Spend Search Router navigation source', () => { expect(rightElement.props).toMatchObject({text: 'Spend', icon: spendContextIcon, iconSize: variables.fontSizeLabel, showTooltip: false}); rerender({shouldWatchForApprovals: true}); - expect(mockUseSearchTypeMenuSections).toHaveBeenLastCalledWith(undefined, true); + expect(mockUseSearchTypeMenuSections).toHaveBeenLastCalledWith(true, undefined); }); it('keeps Create rows reachable when top-level and Spend sources are present', () => { @@ -942,16 +961,12 @@ describe('Spend Search Router navigation source', () => { Building: mockIcon, Gear: mockIcon, }); - mockUseSearchTypeMenuSections.mockReturnValue({ - typeMenuSections: [ - { - translationPath: 'search.tabs.expenseReports', - menuItems: [createSpendMenuItem(CONST.SEARCH.SEARCH_KEYS.REPORTS, 'search.tabs.reports', 'Document', 'type:expense-report')], - }, - ], - activeItemIndex: -1, - activeKey: undefined, - }); + mockUseSearchTypeMenuSections.mockReturnValue([ + { + translationPath: 'search.tabs.expenseReports', + menuItems: [createSpendMenuItem(CONST.SEARCH.SEARCH_KEYS.REPORTS, 'search.tabs.reports', 'Document', 'type:expense-report')], + }, + ]); mockUseCreateNavigationSuggestions.mockReturnValue( CreateNavigationSuggestions.buildCreateNavigationItems([{visible: true, text: 'Create expense', icon: mockIcon, action: jest.fn(), keyForList: 'create_expense'}]), ); @@ -1041,7 +1056,7 @@ describe('Account Search Router navigation source', () => { Building: mockIcon, Gear: accountContextIcon, }); - mockUseSearchTypeMenuSections.mockReturnValue({typeMenuSections: [], activeItemIndex: -1, activeKey: undefined}); + mockUseSearchTypeMenuSections.mockReturnValue([]); mockUseSettingsNavigationMenuData.mockReturnValue({ accountMenuItemsData: { sectionTranslationKey: 'initialSettingsPage.account', diff --git a/tests/unit/TransactionGroupListItemTest.tsx b/tests/unit/TransactionGroupListItemTest.tsx index 813a06ca48e6..0d34b9a2f428 100644 --- a/tests/unit/TransactionGroupListItemTest.tsx +++ b/tests/unit/TransactionGroupListItemTest.tsx @@ -41,6 +41,8 @@ jest.mock('@libs/SearchUIUtils', () => ({ isCorrectSearchUserName: jest.fn(() => true), getTableMinWidth: jest.fn(() => 0), getSuggestedSearches: jest.fn(() => ({})), + getSuggestedSearchesVisibility: jest.fn(() => ({shouldShowExpensifyCard: false})), + isTodoSearch: jest.fn(() => false), getSubmittedViolationsForTransaction: jest.fn(() => ''), })); diff --git a/tests/unit/components/Search/SearchQueryProvider.test.tsx b/tests/unit/components/Search/SearchQueryProvider.test.tsx new file mode 100644 index 000000000000..815ff27bcf8b --- /dev/null +++ b/tests/unit/components/Search/SearchQueryProvider.test.tsx @@ -0,0 +1,396 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import {SearchQueryActionsContext, SearchQueryContext} from '@components/Search/SearchContextDefinitions'; +import SearchQueryProvider from '@components/Search/SearchQueryProvider'; + +import type * as SearchActions from '@libs/actions/Search'; +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import {savedSearchIDToSearchKey} from '@libs/SearchUIUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; + +import type * as ReactNavigation from '@react-navigation/native'; + +import {useContext} from 'react'; + +const SAVED_SEARCH_ID = '100'; + +// The default query string of the "Reconciliation" suggested search, without its `withdrawn` default filter. +const RECONCILIATION_QUERY_WITHOUT_WITHDRAWN = + `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} ` + + `${CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_BY}:${CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWN} ` + + `${CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_ORDER}:${CONST.SEARCH.SORT_ORDER.DESC} ` + + `${CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW}:${CONST.SEARCH.VIEW.TABLE} ` + + `${CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY}:${CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID} ` + + `${CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_TYPE}:${CONST.SEARCH.WITHDRAWAL_TYPE.REIMBURSEMENT}`; + +// The default query string of the "Reconciliation" suggested search. +const RECONCILIATION_QUERY = `${RECONCILIATION_QUERY_WITHOUT_WITHDRAWN} ${CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWN}:${CONST.SEARCH.DATE_PRESETS.LAST_MONTH}`; + +const mockGetDeepestFocusedScreen = jest.fn<{name: string; params: {q?: string; rawQuery?: string}}, []>(); +const mockUseOnyx = jest.fn<[unknown], [key: string]>(); + +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: {}, + getDeepestFocusedScreen: () => mockGetDeepestFocusedScreen(), +})); + +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + return {...actual, useNavigation: () => ({getState: () => undefined})}; +}); + +jest.mock('@hooks/useRootNavigationState', () => ({ + __esModule: true, + default: (selector: (state: unknown) => unknown) => selector(undefined), +})); + +jest.mock('@hooks/useOnyx', () => ({ + __esModule: true, + default: (key: string) => mockUseOnyx(key), +})); + +// A query with a `category` filter makes the provider load the category data, which fires a real API request. +jest.mock('@libs/actions/Search', () => ({ + ...jest.requireActual('@libs/actions/Search'), + openSearchCategoryFiltersPage: jest.fn(), +})); + +function mockNavigationQuery(query: string | undefined, rawQuery?: string) { + mockGetDeepestFocusedScreen.mockReturnValue({name: SCREENS.SEARCH.ROOT, params: {q: query, rawQuery}}); +} + +function mockOnyx(data: Record = {}) { + mockUseOnyx.mockImplementation((key: string) => [data[key]]); +} + +function mockSearchFilter(query: string) { + return {query, timestamp: '2026-08-21 00:00:00.000'}; +} + +function useSearchQuery() { + return {...useContext(SearchQueryContext), ...useContext(SearchQueryActionsContext)}; +} + +function renderProvider() { + return renderHook(useSearchQuery, {wrapper: SearchQueryProvider}); +} + +describe('SearchQueryProvider', () => { + beforeEach(() => { + mockGetDeepestFocusedScreen.mockReset(); + mockUseOnyx.mockReset(); + mockOnyx(); + }); + + describe('initial currentSearchKey', () => { + it('matches a suggested search by its default query', () => { + mockNavigationQuery(RECONCILIATION_QUERY); + + const {result} = renderProvider(); + + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.RECONCILIATION); + }); + + it('matches a suggested search by its last (SEARCH_FILTERS) query', () => { + mockOnyx({[ONYXKEYS.SEARCH_FILTERS]: {[CONST.SEARCH.SEARCH_KEYS.SUBMIT]: mockSearchFilter(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} merchant:Zulu`)}}); + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} merchant:Zulu`); + + const {result} = renderProvider(); + + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.SUBMIT); + }); + + it('ignores a last query that is stored in the legacy string format', () => { + mockOnyx({[ONYXKEYS.SEARCH_FILTERS]: {[CONST.SEARCH.SEARCH_KEYS.SUBMIT]: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} merchant:Zulu`}}); + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} merchant:Zulu`); + + const {result} = renderProvider(); + + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.REPORTS); + }); + + it('matches a suggested search by its default query even when the last query exists', () => { + mockOnyx({ + [ONYXKEYS.SEARCH_FILTERS]: {[CONST.SEARCH.SEARCH_KEYS.RECONCILIATION]: mockSearchFilter(`${RECONCILIATION_QUERY} ${CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM}:123`)}, + }); + mockNavigationQuery(RECONCILIATION_QUERY); + + const {result} = renderProvider(); + + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.RECONCILIATION); + }); + + it('matches a saved search by its default query', () => { + mockOnyx({[ONYXKEYS.SAVED_SEARCHES]: {[SAVED_SEARCH_ID]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`, name: 'My search'}}}); + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`); + + const {result} = renderProvider(); + + expect(result.current.currentSearchKey).toBe(savedSearchIDToSearchKey(SAVED_SEARCH_ID)); + }); + + it('matches a saved search by its last (SEARCH_FILTERS) query', () => { + const savedSearchKey = savedSearchIDToSearchKey(SAVED_SEARCH_ID); + mockOnyx({ + [ONYXKEYS.SAVED_SEARCHES]: {[SAVED_SEARCH_ID]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`, name: 'My search'}}, + [ONYXKEYS.SEARCH_FILTERS]: {[savedSearchKey]: mockSearchFilter(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Starbucks`)}, + }); + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Starbucks`); + + const {result} = renderProvider(); + + expect(result.current.currentSearchKey).toBe(savedSearchKey); + }); + + it('matches a saved search by its default query even when the last query exists', () => { + const savedSearchKey = savedSearchIDToSearchKey(SAVED_SEARCH_ID); + mockOnyx({ + [ONYXKEYS.SAVED_SEARCHES]: {[SAVED_SEARCH_ID]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`, name: 'My search'}}, + [ONYXKEYS.SEARCH_FILTERS]: {[savedSearchKey]: mockSearchFilter(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Starbucks`)}, + }); + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`); + + const {result} = renderProvider(); + + expect(result.current.currentSearchKey).toBe(savedSearchKey); + }); + + it('falls back to the generic expenses key when the type is expense and nothing matches', () => { + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`); + + const {result} = renderProvider(); + + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.EXPENSES); + }); + + it('falls back to the generic reports key when the type is expense report and nothing matches', () => { + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} merchant:Amazon`); + + const {result} = renderProvider(); + + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.REPORTS); + }); + + it('is undefined when the type has no generic key and nothing matches', () => { + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.INVOICE}`); + + const {result} = renderProvider(); + + expect(result.current.currentSearchKey).toBeUndefined(); + }); + }); + + describe('currentDefaultSearchQueryJSON', () => { + it('exposes the default query of the current suggested search', () => { + mockNavigationQuery(RECONCILIATION_QUERY); + + const {result} = renderProvider(); + + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.RECONCILIATION); + expect(result.current.currentDefaultSearchQueryJSON?.hash).toBe(buildSearchQueryJSON(RECONCILIATION_QUERY)?.hash); + expect([...result.current.currentDefaultSearchQueryFilterKeys]).toEqual( + expect.arrayContaining([CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_TYPE, CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWN]), + ); + }); + + it('is empty for a saved search because saved searches have no default filters', () => { + mockOnyx({[ONYXKEYS.SAVED_SEARCHES]: {[SAVED_SEARCH_ID]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`, name: 'My search'}}}); + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`); + + const {result} = renderProvider(); + + expect(result.current.currentSearchKey).toBe(savedSearchIDToSearchKey(SAVED_SEARCH_ID)); + expect(result.current.currentDefaultSearchQueryJSON).toBeUndefined(); + expect(result.current.currentDefaultSearchQueryFilterKeys.size).toBe(0); + }); + }); + + describe('resetting on hash change', () => { + const savedSearches = {[SAVED_SEARCH_ID]: {query: `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`, name: 'My search'}}; + + it('keeps the search key when the new query still has the default filters and same type', () => { + mockNavigationQuery(RECONCILIATION_QUERY); + const {result, rerender} = renderProvider(); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.RECONCILIATION); + + // Adding a filter changes the hash but keeps all the default filters + type, so the key must be preserved. + mockNavigationQuery(`${RECONCILIATION_QUERY} merchant:Amazon`); + rerender(undefined); + + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.RECONCILIATION); + }); + + it('resets the search key when the new query drops a default filter', () => { + mockNavigationQuery(RECONCILIATION_QUERY); + const {result, rerender} = renderProvider(); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.RECONCILIATION); + + mockNavigationQuery(RECONCILIATION_QUERY_WITHOUT_WITHDRAWN); + rerender(undefined); + + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.EXPENSES); + }); + + it('resets the search key when the query type changes', () => { + mockNavigationQuery(RECONCILIATION_QUERY); + const {result, rerender} = renderProvider(); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.RECONCILIATION); + + mockNavigationQuery(RECONCILIATION_QUERY.replace(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE}`, `type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT}`)); + rerender(undefined); + + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.REPORTS); + }); + + it('keeps a saved search key when the query changes since there are no default filters to enforce', () => { + mockOnyx({[ONYXKEYS.SAVED_SEARCHES]: savedSearches}); + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`); + const {result, rerender} = renderProvider(); + expect(result.current.currentSearchKey).toBe(savedSearchIDToSearchKey(SAVED_SEARCH_ID)); + + // The saved search query filters (and even its type) are not enforced, so the key survives the change. + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} category:Food`); + rerender(undefined); + + expect(result.current.currentSearchKey).toBe(savedSearchIDToSearchKey(SAVED_SEARCH_ID)); + }); + + it('recomputes the search key via the resetSearchKey action', () => { + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`); + const {result} = renderProvider(); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.EXPENSES); + + act(() => { + result.current.setCurrentSearchKey(CONST.SEARCH.SEARCH_KEYS.REPORTS); + }); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.REPORTS); + + act(() => { + result.current.resetSearchKey(result.current.currentSearchQueryJSON); + }); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.EXPENSES); + }); + + it('always re-resolves the key, even when the target query hash is unchanged', () => { + const OTHER_SAVED_SEARCH_ID = '200'; + const sharedQuery = `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`; + // Two saved searches with the exact same query. getInitialCurrentSearchKey resolves to the first + // one (id 100), but the current key is the second (id 200). + mockOnyx({ + [ONYXKEYS.SAVED_SEARCHES]: { + [SAVED_SEARCH_ID]: {query: sharedQuery, name: 'First'}, + [OTHER_SAVED_SEARCH_ID]: {query: sharedQuery, name: 'Second'}, + }, + }); + mockNavigationQuery(sharedQuery); + const {result} = renderProvider(); + + act(() => { + result.current.setCurrentSearchKey(savedSearchIDToSearchKey(OTHER_SAVED_SEARCH_ID)); + }); + expect(result.current.currentSearchKey).toBe(savedSearchIDToSearchKey(OTHER_SAVED_SEARCH_ID)); + + // resetSearchKey doesn't special case a target query that resolves to the current search, so it + // switches to whatever getInitialCurrentSearchKey picks (the first saved search, id 100). + act(() => { + result.current.resetSearchKey(buildSearchQueryJSON(sharedQuery)); + }); + expect(result.current.currentSearchKey).toBe(savedSearchIDToSearchKey(SAVED_SEARCH_ID)); + }); + }); + + describe('pending search key', () => { + it('does not apply a pending setCurrentSearchKey until the query hash changes', () => { + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`); + const {result, rerender} = renderProvider(); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.EXPENSES); + + // The target query has a different hash than the current one, so the key must not change yet. + act(() => { + result.current.setCurrentSearchKey(CONST.SEARCH.SEARCH_KEYS.REPORTS, `type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} merchant:Amazon`); + }); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.EXPENSES); + + // Once the query changes, the pending key is applied alongside the new query. + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} merchant:Amazon`); + rerender(undefined); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.REPORTS); + }); + + it('applies setCurrentSearchKey immediately when no target query is passed', () => { + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`); + const {result} = renderProvider(); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.EXPENSES); + + act(() => { + result.current.setCurrentSearchKey(CONST.SEARCH.SEARCH_KEYS.REPORTS); + }); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.REPORTS); + }); + + it('applies setCurrentSearchKey immediately when the target query has the current hash', () => { + const query = `type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`; + mockNavigationQuery(query); + const {result} = renderProvider(); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.EXPENSES); + + // The query isn't changing, so there is nothing to wait for and the key applies right away. + act(() => { + result.current.setCurrentSearchKey(CONST.SEARCH.SEARCH_KEYS.REPORTS, query); + }); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.REPORTS); + }); + + it('pending key wins over the recompute-on-hash-change logic', () => { + mockNavigationQuery(RECONCILIATION_QUERY); + const {result, rerender} = renderProvider(); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.RECONCILIATION); + + // Set a pending key, then change the query so a default filter is dropped. + // Without the pending logic the key would reset to EXPENSES, but the pending key must win. + act(() => { + result.current.setCurrentSearchKey(CONST.SEARCH.SEARCH_KEYS.REPORTS, RECONCILIATION_QUERY_WITHOUT_WITHDRAWN); + }); + mockNavigationQuery(RECONCILIATION_QUERY_WITHOUT_WITHDRAWN); + rerender(undefined); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.REPORTS); + }); + + it('does not apply a pending resetSearchKey until the query hash changes', () => { + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`); + const {result, rerender} = renderProvider(); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.EXPENSES); + + // The reset targets a different query, so nothing changes until the hash catches up. + const nextQueryJSON = buildSearchQueryJSON(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} merchant:Amazon`); + act(() => { + result.current.resetSearchKey(nextQueryJSON); + }); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.EXPENSES); + + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} merchant:Amazon`); + rerender(undefined); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.REPORTS); + }); + + it('applies resetSearchKey immediately when the target query matches the current hash', () => { + mockNavigationQuery(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE} merchant:Amazon`); + const {result} = renderProvider(); + + act(() => { + result.current.setCurrentSearchKey(CONST.SEARCH.SEARCH_KEYS.REPORTS); + }); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.REPORTS); + + // The queryJSON hash equals the current hash, so it applies right away instead of pending. + act(() => { + result.current.resetSearchKey(result.current.currentSearchQueryJSON); + }); + expect(result.current.currentSearchKey).toBe(CONST.SEARCH.SEARCH_KEYS.EXPENSES); + }); + }); +}); diff --git a/tests/unit/hooks/useAutocompleteSuggestions.test.ts b/tests/unit/hooks/useAutocompleteSuggestions.test.ts index 470f30e6be2d..ed7b8f0d650f 100644 --- a/tests/unit/hooks/useAutocompleteSuggestions.test.ts +++ b/tests/unit/hooks/useAutocompleteSuggestions.test.ts @@ -5,6 +5,7 @@ import useNetwork from '@hooks/useNetwork'; import {openSearchCategoryFiltersPage} from '@libs/actions/Search'; import {getSearchOptions} from '@libs/OptionsListUtils'; +import type * as SearchAutocompleteUtils from '@libs/SearchAutocompleteUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -49,6 +50,7 @@ jest.mock('@libs/SearchAutocompleteUtils', () => ({ {taxRateName: 'VAT 20%', taxRateIds: ['vat20']}, {taxRateName: 'GST 10%', taxRateIds: ['gst10']}, ]), + CONTINUATION_DETECTION_SEARCH_FILTER_KEYS: jest.requireActual('@libs/SearchAutocompleteUtils').CONTINUATION_DETECTION_SEARCH_FILTER_KEYS, })); jest.mock('@libs/OptionsListUtils', () => ({ diff --git a/tests/unit/hooks/useSearchFiltersBar.test.tsx b/tests/unit/hooks/useSearchFiltersBar.test.tsx new file mode 100644 index 000000000000..f91cfb584e60 --- /dev/null +++ b/tests/unit/hooks/useSearchFiltersBar.test.tsx @@ -0,0 +1,156 @@ +import {renderHook} from '@testing-library/react-native'; + +import useSearchFiltersBar from '@components/Search/SearchPageHeader/useSearchFiltersBar'; +import type {SearchQueryJSON} from '@components/Search/types'; + +import {setSearchContext} from '@libs/actions/Search'; +import Navigation from '@libs/Navigation/Navigation'; +import {buildQueryStringWithResetFilters} from '@libs/SearchQueryUtils'; + +import CONST from '@src/CONST'; + +const mockSetFilterQueryParams = jest.fn(); +const mockUpdateFilterQueryParams = jest.fn(); +const mockUseSearchResultsContext = jest.fn, []>(); +const mockUseSearchQueryContext = jest.fn, []>(); +const mockMapFiltersFormToLabelValueList = jest.fn(); + +jest.mock('@components/Search/hooks/useUpdateFilterQuery', () => ({ + __esModule: true, + default: () => ({setFilterQueryParams: mockSetFilterQueryParams, updateFilterQueryParams: mockUpdateFilterQueryParams}), +})); + +jest.mock('@libs/SearchUIUtils', () => ({ + mapFiltersFormToLabelValueList: (...args: unknown[]) => mockMapFiltersFormToLabelValueList(...args), +})); + +jest.mock('@components/Search/SearchContext', () => ({ + useSearchResultsContext: () => mockUseSearchResultsContext(), + useSearchQueryContext: () => mockUseSearchQueryContext(), +})); + +jest.mock('@libs/actions/Search'); +jest.mock('@libs/Navigation/Navigation'); + +const queryJSON: SearchQueryJSON = { + hash: 0, + recentSearchHash: 0, + similarSearchHash: 0, + groupBy: undefined, + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + view: CONST.SEARCH.VIEW.TABLE, + flatFilters: [], + inputQuery: '', + filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS, right: ''}, + columns: undefined, + limit: undefined, + rawFilterList: undefined, +}; + +function mockSearchResultsContext(overrides: Record = {}) { + mockUseSearchResultsContext.mockReturnValue({shouldShowFiltersBarLoading: false, currentSearchResults: undefined, ...overrides}); +} + +function mockSearchQueryContext(overrides: Record = {}) { + mockUseSearchQueryContext.mockReturnValue({ + currentDefaultSearchQueryFilterKeys: [], + currentSearchQueryJSON: undefined, + currentDefaultSearchQueryJSON: undefined, + ...overrides, + }); +} + +function buildQueryJSON(flatFilters: SearchQueryJSON['flatFilters']): SearchQueryJSON { + return {...queryJSON, flatFilters}; +} + +const merchantFilters: SearchQueryJSON['flatFilters'] = [{key: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, filters: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: 'Uber'}]}]; +const categoryFilters: SearchQueryJSON['flatFilters'] = [{key: CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY, filters: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: 'Travel'}]}]; + +describe('useSearchFiltersBar', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSearchResultsContext(); + mockSearchQueryContext(); + mockMapFiltersFormToLabelValueList.mockReturnValue([]); + }); + + describe('shouldShowResetFilters', () => { + it('is true when the default query filters differ from the current query filters', () => { + mockSearchQueryContext({ + currentDefaultSearchQueryJSON: buildQueryJSON(merchantFilters), + currentSearchQueryJSON: buildQueryJSON(categoryFilters), + }); + + const {result} = renderHook(() => useSearchFiltersBar(queryJSON)); + + expect(result.current.shouldShowResetFilters).toBe(true); + }); + + it('is false when the default query filters equal the current query filters', () => { + mockSearchQueryContext({ + currentDefaultSearchQueryJSON: buildQueryJSON(merchantFilters), + currentSearchQueryJSON: buildQueryJSON(merchantFilters), + }); + + const {result} = renderHook(() => useSearchFiltersBar(queryJSON)); + + expect(result.current.shouldShowResetFilters).toBe(false); + }); + + it('falls back to having filters when there is no default query JSON', () => { + mockSearchQueryContext(); + mockMapFiltersFormToLabelValueList.mockReturnValue([{key: 'merchant'}]); + + const {result} = renderHook(() => useSearchFiltersBar(queryJSON)); + + expect(result.current.shouldShowResetFilters).toBe(true); + }); + + it('is false when there is no default query JSON and no filters', () => { + mockSearchQueryContext(); + mockMapFiltersFormToLabelValueList.mockReturnValue([]); + + const {result} = renderHook(() => useSearchFiltersBar(queryJSON)); + + expect(result.current.shouldShowResetFilters).toBe(false); + }); + }); + + describe('resetFilters', () => { + it('navigates to the query the filters reset to', () => { + const currentSearchQueryJSON = buildQueryJSON(categoryFilters); + const currentDefaultSearchQueryJSON = buildQueryJSON(merchantFilters); + mockSearchQueryContext({currentSearchQueryJSON, currentDefaultSearchQueryJSON}); + + const {result} = renderHook(() => useSearchFiltersBar(queryJSON)); + result.current.resetFilters(); + + expect(Navigation.setParams).toHaveBeenCalledWith({q: buildQueryStringWithResetFilters(currentSearchQueryJSON, currentDefaultSearchQueryJSON), rawQuery: undefined}); + expect(setSearchContext).toHaveBeenCalledWith(false); + }); + + it('navigates to the query the filters reset to when there is no default query', () => { + const currentSearchQueryJSON = buildQueryJSON(categoryFilters); + mockSearchQueryContext({currentSearchQueryJSON}); + + const {result} = renderHook(() => useSearchFiltersBar(queryJSON)); + result.current.resetFilters(); + + expect(Navigation.setParams).toHaveBeenCalledWith({q: buildQueryStringWithResetFilters(currentSearchQueryJSON, undefined), rawQuery: undefined}); + expect(setSearchContext).toHaveBeenCalledWith(false); + }); + + it('does nothing when there is no current query', () => { + mockSearchQueryContext(); + + const {result} = renderHook(() => useSearchFiltersBar(queryJSON)); + result.current.resetFilters(); + + expect(Navigation.setParams).not.toHaveBeenCalled(); + expect(setSearchContext).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/hooks/useSearchShouldCalculateTotals.test.ts b/tests/unit/hooks/useSearchShouldCalculateTotals.test.ts index 9aeb9b1e596e..4cc4a4d2cf51 100644 --- a/tests/unit/hooks/useSearchShouldCalculateTotals.test.ts +++ b/tests/unit/hooks/useSearchShouldCalculateTotals.test.ts @@ -22,19 +22,19 @@ describe('useSearchShouldCalculateTotals', () => { }); it('returns false when disabled', () => { - const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.SUBMIT, 123, false)); + const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.SUBMIT, false)); expect(result.current).toBe(false); }); it('returns true for eligible suggested searches', () => { - const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.SUBMIT, 123, true)); + const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.SUBMIT, true)); expect(result.current).toBe(true); }); it('returns false for non-eligible searches', () => { - const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.EXPENSES, 123, true)); + const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.EXPENSES, true)); expect(result.current).toBe(false); }); @@ -48,7 +48,7 @@ describe('useSearchShouldCalculateTotals', () => { }, }; - const {result} = renderHook(() => useSearchShouldCalculateTotals(undefined, 456, true)); + const {result} = renderHook(() => useSearchShouldCalculateTotals('savedSearch_456', true)); expect(result.current).toBe(true); }); @@ -62,19 +62,19 @@ describe('useSearchShouldCalculateTotals', () => { }, }; - const {result} = renderHook(() => useSearchShouldCalculateTotals(undefined, 789, true)); + const {result} = renderHook(() => useSearchShouldCalculateTotals('savedSearch_123', true)); expect(result.current).toBe(false); }); it('returns true for an ad-hoc search when all matching items are selected', () => { - const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.EXPENSES, 123, true, true)); + const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.EXPENSES, true, true)); expect(result.current).toBe(true); }); it('returns false for an all-matching selection when the caller has already obtained totals for pagination', () => { - const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.EXPENSES, 123, false, true)); + const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.EXPENSES, false, true)); expect(result.current).toBe(false); }); diff --git a/tests/unit/hooks/useUpdateFilterQuery.test.ts b/tests/unit/hooks/useUpdateFilterQuery.test.ts new file mode 100644 index 000000000000..4fd28127cdb8 --- /dev/null +++ b/tests/unit/hooks/useUpdateFilterQuery.test.ts @@ -0,0 +1,88 @@ +import {renderHook} from '@testing-library/react-native'; + +import useUpdateFilterQuery from '@components/Search/hooks/useUpdateFilterQuery'; +import type {SearchQueryJSON} from '@components/Search/types'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {SearchAdvancedFiltersForm} from '@src/types/form'; + +const onyxData: Record = {}; + +const mockUseOnyx = jest.fn((key: string) => [onyxData[key]]); + +jest.mock('@hooks/useOnyx', () => ({ + __esModule: true, + default: (key: string) => mockUseOnyx(key), +})); + +const mockResetSearchKey = jest.fn(); +const mockUseSearchQueryContext = jest.fn<{currentSearchHash: number}, []>(); + +jest.mock('@components/Search/SearchContext', () => ({ + useSearchQueryActions: () => ({resetSearchKey: mockResetSearchKey}), + useSearchQueryContext: () => mockUseSearchQueryContext(), +})); + +jest.mock('@libs/Navigation/Navigation'); + +const queryJSON: SearchQueryJSON = { + hash: 0, + recentSearchHash: 0, + similarSearchHash: 0, + groupBy: undefined, + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + view: CONST.SEARCH.VIEW.TABLE, + flatFilters: [], + inputQuery: '', + filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS, right: ''}, + columns: undefined, + limit: undefined, + rawFilterList: undefined, +}; + +describe('useUpdateFilterQuery', () => { + beforeEach(() => { + onyxData[ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM] = {type: CONST.SEARCH.DATA_TYPES.EXPENSE} satisfies Partial; + mockResetSearchKey.mockClear(); + mockUseSearchQueryContext.mockReturnValue({currentSearchHash: -1}); + }); + + describe('setFilterQueryParams', () => { + it('resets the search key when the type changes', () => { + const {result} = renderHook(() => useUpdateFilterQuery(queryJSON)); + + result.current.setFilterQueryParams({type: CONST.SEARCH.DATA_TYPES.INVOICE}); + + expect(mockResetSearchKey).toHaveBeenCalledTimes(1); + expect(mockResetSearchKey).toHaveBeenCalledWith(expect.objectContaining({type: CONST.SEARCH.DATA_TYPES.INVOICE})); + }); + + it('does not reset the search key when the new query is the current query', () => { + mockUseSearchQueryContext.mockReturnValue({currentSearchHash: queryJSON.hash}); + const {result} = renderHook(() => useUpdateFilterQuery(queryJSON)); + + result.current.setFilterQueryParams({type: CONST.SEARCH.DATA_TYPES.EXPENSE}); + + expect(mockResetSearchKey).not.toHaveBeenCalled(); + }); + + it('does not reset the search key when the type is unchanged', () => { + const {result} = renderHook(() => useUpdateFilterQuery(queryJSON)); + + result.current.setFilterQueryParams({type: CONST.SEARCH.DATA_TYPES.EXPENSE, merchant: 'Amazon'}); + + expect(mockResetSearchKey).not.toHaveBeenCalled(); + }); + + it('does not reset the search key when no type is provided', () => { + const {result} = renderHook(() => useUpdateFilterQuery(queryJSON)); + + result.current.setFilterQueryParams({merchant: 'Amazon'}); + + expect(mockResetSearchKey).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/pages/Search/SearchSavePageTest.tsx b/tests/unit/pages/Search/SearchSavePageTest.tsx index 7ba205241166..6f4ccde26778 100644 --- a/tests/unit/pages/Search/SearchSavePageTest.tsx +++ b/tests/unit/pages/Search/SearchSavePageTest.tsx @@ -26,7 +26,10 @@ jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); jest.mock('@components/ScreenWrapper', () => jest.fn((props: React.PropsWithChildren) => props.children)); jest.mock('@components/Search/hooks/useFilterFeedValue'); jest.mock('@components/Search/hooks/useFilterTaxRateValue'); -jest.mock('@components/Search/SearchContext', () => ({useSearchQueryContext: jest.fn(() => ({currentSearchQueryJSON: undefined}))})); +jest.mock('@components/Search/SearchContext', () => ({ + useSearchQueryContext: jest.fn(() => ({currentSearchQueryJSON: undefined, currentDefaultSearchQueryFilterKeys: new Set()})), + useSearchQueryActions: jest.fn(() => ({setCurrentSearchKey: jest.fn()})), +})); jest.mock('@expensify/react-native-hybrid-app', () => ({__esModule: true, default: {isHybridApp: jest.fn(() => false)}})); jest.mock('@hooks/useAutoFocusInput', () => jest.fn(() => ({inputCallbackRef: jest.fn()}))); jest.mock('@hooks/useCurrencyList', () => ({useCurrencyListActions: jest.fn(() => ({convertToDisplayStringWithoutCurrency: jest.fn()}))})); diff --git a/tests/unit/useShareSavedSearchTest.ts b/tests/unit/useShareSavedSearchTest.ts index 7990614f8bac..79fec7997ca6 100644 --- a/tests/unit/useShareSavedSearchTest.ts +++ b/tests/unit/useShareSavedSearchTest.ts @@ -14,7 +14,7 @@ jest.mock('@hooks/useEnvironment', () => jest.fn(() => ({environmentURL: 'https: const mockClipboardSetString = jest.mocked(Clipboard.setString); -const ITEM_HASH = 12345; +const ITEM_ID = '12345'; const ITEM_QUERY = 'type:expense status:all'; describe('useShareSavedSearch', () => { @@ -27,77 +27,77 @@ describe('useShareSavedSearch', () => { jest.useRealTimers(); }); - it('copies the correct URL to clipboard and sets copiedHash', () => { + it('copies the correct URL to clipboard and sets copiedID', () => { const {result} = renderHook(() => useShareSavedSearch()); act(() => { - result.current.handleShare(ITEM_HASH, ITEM_QUERY); + result.current.handleShare(ITEM_ID, ITEM_QUERY); }); const expectedURL = `https://new.expensify.com/${ROUTES.SEARCH_ROOT.getRoute({query: ITEM_QUERY})}`; expect(mockClipboardSetString).toHaveBeenCalledWith(expectedURL); - expect(result.current.copiedHash).toBe(ITEM_HASH); + expect(result.current.copiedID).toBe(ITEM_ID); }); - it('resets copiedHash to null after 1800ms', () => { + it('resets copiedID to null after 1800ms', () => { const {result} = renderHook(() => useShareSavedSearch()); act(() => { - result.current.handleShare(ITEM_HASH, ITEM_QUERY); + result.current.handleShare(ITEM_ID, ITEM_QUERY); }); - expect(result.current.copiedHash).toBe(ITEM_HASH); + expect(result.current.copiedID).toBe(ITEM_ID); act(() => { jest.advanceTimersByTime(1800); }); - expect(result.current.copiedHash).toBeNull(); + expect(result.current.copiedID).toBeNull(); }); - it('does not reset copiedHash before 1800ms elapses', () => { + it('does not reset copiedID before 1800ms elapses', () => { const {result} = renderHook(() => useShareSavedSearch()); act(() => { - result.current.handleShare(ITEM_HASH, ITEM_QUERY); + result.current.handleShare(ITEM_ID, ITEM_QUERY); }); act(() => { jest.advanceTimersByTime(1799); }); - expect(result.current.copiedHash).toBe(ITEM_HASH); + expect(result.current.copiedID).toBe(ITEM_ID); }); it('resets timer when handleShare is called again before timeout', () => { const {result} = renderHook(() => useShareSavedSearch()); - const secondHash = 99999; + const secondID = '99999'; act(() => { - result.current.handleShare(ITEM_HASH, ITEM_QUERY); + result.current.handleShare(ITEM_ID, ITEM_QUERY); }); act(() => { jest.advanceTimersByTime(1000); - result.current.handleShare(secondHash, ITEM_QUERY); + result.current.handleShare(secondID, ITEM_QUERY); }); // First hash should be replaced by second - expect(result.current.copiedHash).toBe(secondHash); + expect(result.current.copiedID).toBe(secondID); // Advancing 1800ms from second call — should reset act(() => { jest.advanceTimersByTime(1800); }); - expect(result.current.copiedHash).toBeNull(); + expect(result.current.copiedID).toBeNull(); }); it('clears timer on unmount without calling setState', () => { const {result, unmount} = renderHook(() => useShareSavedSearch()); act(() => { - result.current.handleShare(ITEM_HASH, ITEM_QUERY); + result.current.handleShare(ITEM_ID, ITEM_QUERY); }); // Unmount before timer fires diff --git a/tests/utils/MockSearchContextProvider.tsx b/tests/utils/MockSearchContextProvider.tsx index a27821d03796..cc9b4181133f 100644 --- a/tests/utils/MockSearchContextProvider.tsx +++ b/tests/utils/MockSearchContextProvider.tsx @@ -36,6 +36,8 @@ function splitState(value: SearchStateContextValue): { currentSimilarSearchHash: value.currentSimilarSearchHash, currentSearchKey: value.currentSearchKey, currentSearchQueryJSON: value.currentSearchQueryJSON, + currentDefaultSearchQueryJSON: value.currentDefaultSearchQueryJSON, + currentDefaultSearchQueryFilterKeys: value.currentDefaultSearchQueryFilterKeys, suggestedSearches: value.suggestedSearches, shouldResetSearchQuery: value.shouldResetSearchQuery, }, @@ -67,7 +69,7 @@ function splitActions(value: SearchActionsContextValue): { selection: SearchSelectionActionsValue; } { return { - query: {setShouldResetSearchQuery: value.setShouldResetSearchQuery}, + query: {setShouldResetSearchQuery: value.setShouldResetSearchQuery, setCurrentSearchKey: value.setCurrentSearchKey, resetSearchKey: value.resetSearchKey}, results: { setSortedReportIDs: value.setSortedReportIDs, setShouldShowFiltersBarLoading: value.setShouldShowFiltersBarLoading,