diff --git a/contributingGuides/features/Search.md b/contributingGuides/features/Search.md new file mode 100644 index 000000000000..2594504728a1 --- /dev/null +++ b/contributingGuides/features/Search.md @@ -0,0 +1,54 @@ +--- +title: Search Functionality in New Expensify +description: Learn how to effectively use the powerful search feature in New Expensify to find and filter your financial data quickly and efficiently. +--- +
+ +The Search functionality in New Expensify provides a powerful way to locate and filter financial data. +With advanced autocomplete suggestions and predefined filters, you can quickly find specific expenses, reports, +invoices, and more across the platform. + +## Main Uses + +- **Locate specific transactions** - Quickly find expenses, invoices, or other financial data using keywords or filters. +- **Filter by status** - Easily view items based on their current state (Drafts, Outstanding, Approved, etc.). +- **Save frequent searches** - Store commonly used search parameters for quick access in the future. + +## Search Components +The search functionality in Expensify consists of several key components: + +- **Search input** - Located at the top of the Reports view for entering search terms. +- **Left-hand navigation (LHN)** - Allows switching between different data types and saved searches. +- **Predefined filters** - Quick-access filters at the top of each list view. +- **Autocomplete modal** - Suggestions that appear as you type in the search input. +- **Results list** - The formatted list of search results displayed below the filters, showing matching items based on +your search criteria and selected filters. + +# How Search Works +## Basic Navigation +1. When you select a data type from the LHN (Expenses, Expense Reports, Chats, Invoices, or Trips), the system calls the `/Search` endpoint to display results. +2. Selecting a predefined filter (All, Drafts, Outstanding, etc.) also triggers the `/Search` endpoint with the appropriate parameters. + +## Using Predefined Filters +Each data type offers specific predefined filters for quick access: + +1. Navigate to the desired data type view (e.g., Expenses) via the LHN. +2. At the top of the list, select one of the predefined filters: + - **All** - Shows all items (default selection) + - **Drafts** - Items not yet submitted + - **Outstanding** - Items awaiting action + - **Approved** - Items that have received approval + - **Done** - Completed items + - **Paid** - Items that have been reimbursed or paid + +Note: Available filters may vary depending on the data type selected. + +## Using the Search Input +The search input provides powerful functionality: +1. Click in the search field at the top of the Reports view. +2. Begin typing your search term. +3. The autocomplete modal appears with suggestions: + - The first option always shows your exact search text with a magnifying glass icon + - Additional suggestions based on your input from `/SearchForReports` and Onyx data +4. Select first suggestion (magnifying glass icon with search text) or press Enter to execute the search (`/Search` endpoint call). +5. Clicking on any other suggestion (retrieved from either `/SearchForReports` or Onyx data) directly opens the selected report by calling the `/OpenReport` endpoint. diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 9cc8bc9164df..db43dab8863f 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -17,7 +17,6 @@ import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import {searchInServer} from '@libs/actions/Report'; import {getCardFeedKey, getCardFeedNamesWithType} from '@libs/CardFeedUtils'; import {getCardDescription, isCard, isCardHiddenFromSearch, mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; import memoize from '@libs/memoize'; @@ -35,7 +34,7 @@ import { getQueryWithoutAutocompletedPart, parseForAutocomplete, } from '@libs/SearchAutocompleteUtils'; -import {buildSearchQueryJSON, buildUserReadableQueryString, sanitizeSearchValue, shouldHighlight} from '@libs/SearchQueryUtils'; +import {buildSearchQueryJSON, buildUserReadableQueryString, getQueryWithoutFilters, sanitizeSearchValue, shouldHighlight} from '@libs/SearchQueryUtils'; import StringUtils from '@libs/StringUtils'; import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; @@ -57,6 +56,9 @@ type SearchAutocompleteListProps = { /** Value of TextInput */ autocompleteQueryValue: string; + /** Callback to trigger search action * */ + handleSearch: (value: string) => void; + /** An optional item to always display on the top of the router list */ searchQueryItem?: SearchQueryItem; @@ -133,6 +135,7 @@ function SearchRouterItem(props: UserListItemProps | SearchQueryList function SearchAutocompleteList( { autocompleteQueryValue, + handleSearch, searchQueryItem, getAdditionalSections, onListItemPress, @@ -266,8 +269,13 @@ function SearchAutocompleteList( }, [activeWorkspaceID, allPoliciesTags]); const recentTagsAutocompleteList = getAutocompleteRecentTags(allRecentTags, activeWorkspaceID); + const [autocompleteParsedQuery, autocompleteQueryWithoutFilters] = useMemo(() => { + const parsedQuery = parseForAutocomplete(autocompleteQueryValue); + const queryWithoutFilters = getQueryWithoutFilters(autocompleteQueryValue); + return [parsedQuery, queryWithoutFilters]; + }, [autocompleteQueryValue]); + const autocompleteSuggestions = useMemo(() => { - const autocompleteParsedQuery = parseForAutocomplete(autocompleteQueryValue); const {autocomplete, ranges = []} = autocompleteParsedQuery ?? {}; const autocompleteKey = autocomplete?.key; const autocompleteValue = autocomplete?.value ?? ''; @@ -451,7 +459,7 @@ function SearchAutocompleteList( } } }, [ - autocompleteQueryValue, + autocompleteParsedQuery, tagAutocompleteList, recentTagsAutocompleteList, categoryAutocompleteList, @@ -514,8 +522,12 @@ function SearchAutocompleteList( }, [autocompleteQueryValue, filterOptions, searchOptions]); useEffect(() => { - searchInServer(autocompleteQueryValue.trim()); - }, [autocompleteQueryValue]); + if (!handleSearch) { + return; + } + + handleSearch(autocompleteQueryWithoutFilters); + }, [autocompleteQueryWithoutFilters, handleSearch]); /* Sections generation */ const sections: Array> = []; diff --git a/src/components/Search/SearchPageHeader/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader/SearchPageHeader.tsx index 3e3c3f9d4259..62244fd382c2 100644 --- a/src/components/Search/SearchPageHeader/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader/SearchPageHeader.tsx @@ -28,24 +28,25 @@ type SearchPageHeaderProps = { hideSearchRouterList?: () => void; onSearchRouterFocus?: () => void; headerButtonsOptions: Array>; + handleSearch: (value: string) => void; }; type SearchHeaderOptionValue = DeepValueOf | undefined; -function SearchPageHeader({queryJSON, searchName, searchRouterListVisible, hideSearchRouterList, onSearchRouterFocus, headerButtonsOptions}: SearchPageHeaderProps) { +function SearchPageHeader({queryJSON, searchName, searchRouterListVisible, hideSearchRouterList, onSearchRouterFocus, headerButtonsOptions, handleSearch}: SearchPageHeaderProps) { const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {selectedTransactions} = useSearchContext(); - const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE); + const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE, {canBeMissing: true}); const personalDetails = usePersonalDetails(); - const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: true}); const taxRates = getAllTaxRates(); - const [userCardList] = useOnyx(ONYXKEYS.CARD_LIST); - const [workspaceCardFeeds] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST); + const [userCardList] = useOnyx(ONYXKEYS.CARD_LIST, {canBeMissing: true}); + const [workspaceCardFeeds] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, {canBeMissing: true}); const allCards = useMemo(() => mergeCardListWithWorkspaceFeeds(workspaceCardFeeds ?? CONST.EMPTY_OBJECT, userCardList), [userCardList, workspaceCardFeeds]); - const [currencyList = {}] = useOnyx(ONYXKEYS.CURRENCY_LIST); - const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); - const [policyTagsLists] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); + const [currencyList = {}] = useOnyx(ONYXKEYS.CURRENCY_LIST, {canBeMissing: true}); + const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES, {canBeMissing: true}); + const [policyTagsLists] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS, {canBeMissing: true}); const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); @@ -85,6 +86,7 @@ function SearchPageHeader({queryJSON, searchName, searchRouterListVisible, hideS searchName={searchName} hideSearchRouterList={hideSearchRouterList} inputRightComponent={InputRightComponent} + handleSearch={handleSearch} /> ); } diff --git a/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx b/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx index b2729852f902..16dcb0177e9d 100644 --- a/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx +++ b/src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx @@ -50,9 +50,10 @@ type SearchPageHeaderInputProps = { onSearchRouterFocus?: () => void; searchName?: string; inputRightComponent: React.ReactNode; + handleSearch: (value: string) => void; }; -function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRouterList, onSearchRouterFocus, searchName, inputRightComponent}: SearchPageHeaderInputProps) { +function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRouterList, onSearchRouterFocus, searchName, inputRightComponent, handleSearch}: SearchPageHeaderInputProps) { const {translate} = useLocalize(); const [showPopupButton, setShowPopupButton] = useState(true); const styles = useThemeStyles(); @@ -83,9 +84,14 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo const [isAutocompleteListVisible, setIsAutocompleteListVisible] = useState(false); const listRef = useRef(null); const textInputRef = useRef(null); + const hasMountedRef = useRef(false); const isFocused = useIsFocused(); const {registerSearchPageInput} = useSearchRouterContext(); + useEffect(() => { + hasMountedRef.current = true; + }, []); + // useEffect for blurring TextInput when we cancel SearchRouter interaction on narrow layout useEffect(() => { if (!displayNarrowHeader || !!searchRouterListVisible || !textInputRef.current || !textInputRef.current.isFocused()) { @@ -132,6 +138,16 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const handleSearchAction = useCallback( + (value: string) => { + // Skip calling handleSearch on the initial mount + if (!hasMountedRef.current) { + return; + } + handleSearch(value); + }, + [handleSearch], + ); const onSearchQueryChange = useCallback( (userQuery: string) => { const singleLineUserQuery = StringUtils.lineBreaksToSpaces(userQuery, true); @@ -279,6 +295,7 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo ; currentSearchResults?: SearchResults; lastNonEmptySearchResults?: SearchResults; + handleSearch: (value: SearchParams) => void; }; function mapTransactionItemToSelectedEntry(item: TransactionListItemType): [string, SelectedTransactionInfo] { @@ -127,7 +128,7 @@ function prepareTransactionsList(item: TransactionListItemType, selectedTransact }; } -function Search({queryJSON, currentSearchResults, lastNonEmptySearchResults, onSearchListScroll, contentContainerStyle}: SearchProps) { +function Search({queryJSON, currentSearchResults, lastNonEmptySearchResults, onSearchListScroll, contentContainerStyle, handleSearch}: SearchProps) { const {isOffline} = useNetwork(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const styles = useThemeStyles(); @@ -201,8 +202,8 @@ function Search({queryJSON, currentSearchResults, lastNonEmptySearchResults, onS return; } - search({queryJSON, offset}); - }, [isOffline, offset, queryJSON]); + handleSearch({queryJSON, offset}); + }, [handleSearch, isOffline, offset, queryJSON]); const {newSearchResultKey, handleSelectionListScroll} = useSearchHighlightAndScroll({ searchResults, diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 335b8cf103b5..a10bbd5f8733 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -154,6 +154,11 @@ type SearchAutocompleteQueryRange = { value: string; }; +type SearchParams = { + queryJSON: SearchQueryJSON; + offset: number; +}; + export type { SelectedTransactionInfo, SelectedTransactions, @@ -178,6 +183,7 @@ export type { SearchAutocompleteResult, PaymentData, SearchAutocompleteQueryRange, + SearchParams, TableColumnSize, SearchGroupBy, }; diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index ba1c985eaef0..e18fa83c8c8a 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -827,6 +827,25 @@ function getCurrentSearchQueryJSON() { return queryJSON; } +/** + * Extracts the query text without the filter parts. + * This is used to determine if a user's core search terms have changed, + * ignoring any filter modifications. + * + * @param searchQuery - The complete search query string + * @returns The query without filters (core search terms only) + */ +function getQueryWithoutFilters(searchQuery: string) { + const queryJSON = buildSearchQueryJSON(searchQuery); + if (!queryJSON) { + return ''; + } + + const keywordFilter = queryJSON.flatFilters.find((filter) => filter.key === 'keyword'); + + return keywordFilter?.filters.map((filter) => filter.value).join(' ') ?? ''; +} + /** * Converts a filter key from old naming (camelCase) to user friendly naming (kebab-case). * @@ -869,6 +888,7 @@ export { sanitizeSearchValue, getQueryWithUpdatedValues, getCurrentSearchQueryJSON, + getQueryWithoutFilters, getUserFriendlyKey, isDefaultExpensesQuery, shouldHighlight, diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 111d072d6a0f..67b978b97ed4 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -15,7 +15,7 @@ import {useSearchContext} from '@components/Search/SearchContext'; import type {SearchHeaderOptionValue} from '@components/Search/SearchPageHeader/SearchPageHeader'; import SearchPageHeader from '@components/Search/SearchPageHeader/SearchPageHeader'; import SearchStatusBar from '@components/Search/SearchPageHeader/SearchStatusBar'; -import type {PaymentData} from '@components/Search/types'; +import type {PaymentData, SearchParams} from '@components/Search/types'; import {usePlaybackContext} from '@components/VideoPlayerContexts/PlaybackContext'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useLocalize from '@hooks/useLocalize'; @@ -25,6 +25,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import {searchInServer} from '@libs/actions/Report'; import { approveMoneyRequestOnSearch, deleteMoneyRequestOnSearch, @@ -32,6 +33,7 @@ import { getLastPolicyPaymentMethod, payMoneyRequestOnSearch, queueExportSearchItemsToCSV, + search, unholdMoneyRequestOnSearch, } from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; @@ -395,6 +397,14 @@ function SearchPage({route}: SearchPageProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const handleSearchAction = useCallback((value: SearchParams | string) => { + if (typeof value === 'string') { + searchInServer(value); + } else { + search(value); + } + }, []); + if (shouldUseNarrowLayout) { return ( <> @@ -487,6 +497,7 @@ function SearchPage({route}: SearchPageProps) { diff --git a/src/pages/Search/SearchPageNarrow.tsx b/src/pages/Search/SearchPageNarrow.tsx index 8d938c9b269f..3993df9bf495 100644 --- a/src/pages/Search/SearchPageNarrow.tsx +++ b/src/pages/Search/SearchPageNarrow.tsx @@ -14,7 +14,7 @@ import {useSearchContext} from '@components/Search/SearchContext'; import SearchPageHeader from '@components/Search/SearchPageHeader/SearchPageHeader'; import type {SearchHeaderOptionValue} from '@components/Search/SearchPageHeader/SearchPageHeader'; import SearchStatusBar from '@components/Search/SearchPageHeader/SearchStatusBar'; -import type {SearchQueryJSON} from '@components/Search/types'; +import type {SearchParams, SearchQueryJSON} from '@components/Search/types'; import useHandleBackButton from '@hooks/useHandleBackButton'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -28,6 +28,8 @@ import Navigation from '@libs/Navigation/Navigation'; import {buildCannedSearchQuery, isCannedSearchQuery} from '@libs/SearchQueryUtils'; import {isSearchDataLoaded} from '@libs/SearchUIUtils'; import variables from '@styles/variables'; +import {searchInServer} from '@userActions/Report'; +import {search} from '@userActions/Search'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {SearchResults} from '@src/types/onyx'; @@ -107,6 +109,14 @@ function SearchPageNarrow({queryJSON, searchName, headerButtonsOptions, currentS Navigation.goBack(ROUTES.SEARCH_ROOT.getRoute({query: buildCannedSearchQuery()})); }, [searchRouterListVisible]); + const handleSearchAction = useCallback((value: SearchParams | string) => { + if (typeof value === 'string') { + searchInServer(value); + } else { + search(value); + } + }, []); + if (!queryJSON) { return ( @@ -191,6 +202,7 @@ function SearchPageNarrow({queryJSON, searchName, headerButtonsOptions, currentS queryJSON={queryJSON} searchName={searchName} headerButtonsOptions={headerButtonsOptions} + handleSearch={handleSearchAction} /> )} @@ -203,6 +215,7 @@ function SearchPageNarrow({queryJSON, searchName, headerButtonsOptions, currentS queryJSON={queryJSON} onSearchListScroll={scrollHandler} contentContainerStyle={!selectionMode?.isEnabled ? styles.searchListContentContainerStyles : undefined} + handleSearch={handleSearchAction} /> )}