Skip to content

Commit 67cc8ab

Browse files
authored
Merge pull request #60810 from callstack-internal/avoid-duplicated-search-for-reports-calls
fix: avoid duplicated /SearchForReport calls
2 parents 579d3aa + 2f7f97f commit 67cc8ab

File tree

10 files changed

+162
-23
lines changed

10 files changed

+162
-23
lines changed

contributingGuides/features/Search.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
title: Search Functionality in New Expensify
3+
description: Learn how to effectively use the powerful search feature in New Expensify to find and filter your financial data quickly and efficiently.
4+
---
5+
<div id="expensify-classic" markdown="1">
6+
7+
The Search functionality in New Expensify provides a powerful way to locate and filter financial data.
8+
With advanced autocomplete suggestions and predefined filters, you can quickly find specific expenses, reports,
9+
invoices, and more across the platform.
10+
11+
## Main Uses
12+
13+
- **Locate specific transactions** - Quickly find expenses, invoices, or other financial data using keywords or filters.
14+
- **Filter by status** - Easily view items based on their current state (Drafts, Outstanding, Approved, etc.).
15+
- **Save frequent searches** - Store commonly used search parameters for quick access in the future.
16+
17+
## Search Components
18+
The search functionality in Expensify consists of several key components:
19+
20+
- **Search input** - Located at the top of the Reports view for entering search terms.
21+
- **Left-hand navigation (LHN)** - Allows switching between different data types and saved searches.
22+
- **Predefined filters** - Quick-access filters at the top of each list view.
23+
- **Autocomplete modal** - Suggestions that appear as you type in the search input.
24+
- **Results list** - The formatted list of search results displayed below the filters, showing matching items based on
25+
your search criteria and selected filters.
26+
27+
# How Search Works
28+
## Basic Navigation
29+
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.
30+
2. Selecting a predefined filter (All, Drafts, Outstanding, etc.) also triggers the `/Search` endpoint with the appropriate parameters.
31+
32+
## Using Predefined Filters
33+
Each data type offers specific predefined filters for quick access:
34+
35+
1. Navigate to the desired data type view (e.g., Expenses) via the LHN.
36+
2. At the top of the list, select one of the predefined filters:
37+
- **All** - Shows all items (default selection)
38+
- **Drafts** - Items not yet submitted
39+
- **Outstanding** - Items awaiting action
40+
- **Approved** - Items that have received approval
41+
- **Done** - Completed items
42+
- **Paid** - Items that have been reimbursed or paid
43+
44+
Note: Available filters may vary depending on the data type selected.
45+
46+
## Using the Search Input
47+
The search input provides powerful functionality:
48+
1. Click in the search field at the top of the Reports view.
49+
2. Begin typing your search term.
50+
3. The autocomplete modal appears with suggestions:
51+
- The first option always shows your exact search text with a magnifying glass icon
52+
- Additional suggestions based on your input from `/SearchForReports` and Onyx data
53+
4. Select first suggestion (magnifying glass icon with search text) or press Enter to execute the search (`/Search` endpoint call).
54+
5. Clicking on any other suggestion (retrieved from either `/SearchForReports` or Onyx data) directly opens the selected report by calling the `/OpenReport` endpoint.

src/components/Search/SearchAutocompleteList.tsx

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import useLocalize from '@hooks/useLocalize';
1717
import usePolicy from '@hooks/usePolicy';
1818
import useResponsiveLayout from '@hooks/useResponsiveLayout';
1919
import useThemeStyles from '@hooks/useThemeStyles';
20-
import {searchInServer} from '@libs/actions/Report';
2120
import {getCardFeedKey, getCardFeedNamesWithType} from '@libs/CardFeedUtils';
2221
import {getCardDescription, isCard, isCardHiddenFromSearch, mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils';
2322
import memoize from '@libs/memoize';
@@ -35,7 +34,7 @@ import {
3534
getQueryWithoutAutocompletedPart,
3635
parseForAutocomplete,
3736
} from '@libs/SearchAutocompleteUtils';
38-
import {buildSearchQueryJSON, buildUserReadableQueryString, sanitizeSearchValue, shouldHighlight} from '@libs/SearchQueryUtils';
37+
import {buildSearchQueryJSON, buildUserReadableQueryString, getQueryWithoutFilters, sanitizeSearchValue, shouldHighlight} from '@libs/SearchQueryUtils';
3938
import StringUtils from '@libs/StringUtils';
4039
import Timing from '@userActions/Timing';
4140
import CONST from '@src/CONST';
@@ -57,6 +56,9 @@ type SearchAutocompleteListProps = {
5756
/** Value of TextInput */
5857
autocompleteQueryValue: string;
5958

59+
/** Callback to trigger search action * */
60+
handleSearch: (value: string) => void;
61+
6062
/** An optional item to always display on the top of the router list */
6163
searchQueryItem?: SearchQueryItem;
6264

@@ -133,6 +135,7 @@ function SearchRouterItem(props: UserListItemProps<OptionData> | SearchQueryList
133135
function SearchAutocompleteList(
134136
{
135137
autocompleteQueryValue,
138+
handleSearch,
136139
searchQueryItem,
137140
getAdditionalSections,
138141
onListItemPress,
@@ -266,8 +269,13 @@ function SearchAutocompleteList(
266269
}, [activeWorkspaceID, allPoliciesTags]);
267270
const recentTagsAutocompleteList = getAutocompleteRecentTags(allRecentTags, activeWorkspaceID);
268271

272+
const [autocompleteParsedQuery, autocompleteQueryWithoutFilters] = useMemo(() => {
273+
const parsedQuery = parseForAutocomplete(autocompleteQueryValue);
274+
const queryWithoutFilters = getQueryWithoutFilters(autocompleteQueryValue);
275+
return [parsedQuery, queryWithoutFilters];
276+
}, [autocompleteQueryValue]);
277+
269278
const autocompleteSuggestions = useMemo<AutocompleteItemData[]>(() => {
270-
const autocompleteParsedQuery = parseForAutocomplete(autocompleteQueryValue);
271279
const {autocomplete, ranges = []} = autocompleteParsedQuery ?? {};
272280
const autocompleteKey = autocomplete?.key;
273281
const autocompleteValue = autocomplete?.value ?? '';
@@ -451,7 +459,7 @@ function SearchAutocompleteList(
451459
}
452460
}
453461
}, [
454-
autocompleteQueryValue,
462+
autocompleteParsedQuery,
455463
tagAutocompleteList,
456464
recentTagsAutocompleteList,
457465
categoryAutocompleteList,
@@ -514,8 +522,12 @@ function SearchAutocompleteList(
514522
}, [autocompleteQueryValue, filterOptions, searchOptions]);
515523

516524
useEffect(() => {
517-
searchInServer(autocompleteQueryValue.trim());
518-
}, [autocompleteQueryValue]);
525+
if (!handleSearch) {
526+
return;
527+
}
528+
529+
handleSearch(autocompleteQueryWithoutFilters);
530+
}, [autocompleteQueryWithoutFilters, handleSearch]);
519531

520532
/* Sections generation */
521533
const sections: Array<SectionListDataType<OptionData | SearchQueryItem>> = [];

src/components/Search/SearchPageHeader/SearchPageHeader.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,24 +28,25 @@ type SearchPageHeaderProps = {
2828
hideSearchRouterList?: () => void;
2929
onSearchRouterFocus?: () => void;
3030
headerButtonsOptions: Array<DropdownOption<SearchHeaderOptionValue>>;
31+
handleSearch: (value: string) => void;
3132
};
3233

3334
type SearchHeaderOptionValue = DeepValueOf<typeof CONST.SEARCH.BULK_ACTION_TYPES> | undefined;
3435

35-
function SearchPageHeader({queryJSON, searchName, searchRouterListVisible, hideSearchRouterList, onSearchRouterFocus, headerButtonsOptions}: SearchPageHeaderProps) {
36+
function SearchPageHeader({queryJSON, searchName, searchRouterListVisible, hideSearchRouterList, onSearchRouterFocus, headerButtonsOptions, handleSearch}: SearchPageHeaderProps) {
3637
const styles = useThemeStyles();
3738
const {shouldUseNarrowLayout} = useResponsiveLayout();
3839
const {selectedTransactions} = useSearchContext();
39-
const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE);
40+
const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE, {canBeMissing: true});
4041
const personalDetails = usePersonalDetails();
41-
const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
42+
const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: true});
4243
const taxRates = getAllTaxRates();
43-
const [userCardList] = useOnyx(ONYXKEYS.CARD_LIST);
44-
const [workspaceCardFeeds] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST);
44+
const [userCardList] = useOnyx(ONYXKEYS.CARD_LIST, {canBeMissing: true});
45+
const [workspaceCardFeeds] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, {canBeMissing: true});
4546
const allCards = useMemo(() => mergeCardListWithWorkspaceFeeds(workspaceCardFeeds ?? CONST.EMPTY_OBJECT, userCardList), [userCardList, workspaceCardFeeds]);
46-
const [currencyList = {}] = useOnyx(ONYXKEYS.CURRENCY_LIST);
47-
const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES);
48-
const [policyTagsLists] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS);
47+
const [currencyList = {}] = useOnyx(ONYXKEYS.CURRENCY_LIST, {canBeMissing: true});
48+
const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES, {canBeMissing: true});
49+
const [policyTagsLists] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS, {canBeMissing: true});
4950

5051
const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {});
5152

@@ -85,6 +86,7 @@ function SearchPageHeader({queryJSON, searchName, searchRouterListVisible, hideS
8586
searchName={searchName}
8687
hideSearchRouterList={hideSearchRouterList}
8788
inputRightComponent={InputRightComponent}
89+
handleSearch={handleSearch}
8890
/>
8991
);
9092
}

src/components/Search/SearchPageHeader/SearchPageHeaderInput.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,10 @@ type SearchPageHeaderInputProps = {
5050
onSearchRouterFocus?: () => void;
5151
searchName?: string;
5252
inputRightComponent: React.ReactNode;
53+
handleSearch: (value: string) => void;
5354
};
5455

55-
function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRouterList, onSearchRouterFocus, searchName, inputRightComponent}: SearchPageHeaderInputProps) {
56+
function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRouterList, onSearchRouterFocus, searchName, inputRightComponent, handleSearch}: SearchPageHeaderInputProps) {
5657
const {translate} = useLocalize();
5758
const [showPopupButton, setShowPopupButton] = useState(true);
5859
const styles = useThemeStyles();
@@ -83,9 +84,14 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo
8384
const [isAutocompleteListVisible, setIsAutocompleteListVisible] = useState(false);
8485
const listRef = useRef<SelectionListHandle>(null);
8586
const textInputRef = useRef<AnimatedTextInputRef>(null);
87+
const hasMountedRef = useRef(false);
8688
const isFocused = useIsFocused();
8789
const {registerSearchPageInput} = useSearchRouterContext();
8890

91+
useEffect(() => {
92+
hasMountedRef.current = true;
93+
}, []);
94+
8995
// useEffect for blurring TextInput when we cancel SearchRouter interaction on narrow layout
9096
useEffect(() => {
9197
if (!displayNarrowHeader || !!searchRouterListVisible || !textInputRef.current || !textInputRef.current.isFocused()) {
@@ -132,6 +138,16 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo
132138
// eslint-disable-next-line react-hooks/exhaustive-deps
133139
}, []);
134140

141+
const handleSearchAction = useCallback(
142+
(value: string) => {
143+
// Skip calling handleSearch on the initial mount
144+
if (!hasMountedRef.current) {
145+
return;
146+
}
147+
handleSearch(value);
148+
},
149+
[handleSearch],
150+
);
135151
const onSearchQueryChange = useCallback(
136152
(userQuery: string) => {
137153
const singleLineUserQuery = StringUtils.lineBreaksToSpaces(userQuery, true);
@@ -279,6 +295,7 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo
279295
<View style={[styles.flex1]}>
280296
<SearchAutocompleteList
281297
autocompleteQueryValue={autocompleteQueryValue}
298+
handleSearch={handleSearchAction}
282299
searchQueryItem={searchQueryItem}
283300
onListItemPress={onListItemPress}
284301
setTextQuery={setTextAndUpdateSelection}
@@ -345,6 +362,7 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo
345362
<View style={[styles.mh65vh, !isAutocompleteListVisible && styles.dNone]}>
346363
<SearchAutocompleteList
347364
autocompleteQueryValue={autocompleteQueryValue}
365+
handleSearch={handleSearchAction}
348366
searchQueryItem={searchQueryItem}
349367
onListItemPress={onListItemPress}
350368
setTextQuery={setTextAndUpdateSelection}

src/components/Search/SearchRouter/SearchRouter.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import StringUtils from '@libs/StringUtils';
3030
import Navigation from '@navigation/Navigation';
3131
import type {ReportsSplitNavigatorParamList} from '@navigation/types';
3232
import variables from '@styles/variables';
33-
import {navigateToAndOpenReport} from '@userActions/Report';
33+
import {navigateToAndOpenReport, searchInServer} from '@userActions/Report';
3434
import CONST from '@src/CONST';
3535
import ONYXKEYS from '@src/ONYXKEYS';
3636
import ROUTES from '@src/ROUTES';
@@ -360,6 +360,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla
360360
/>
361361
<SearchAutocompleteList
362362
autocompleteQueryValue={autocompleteQueryValue || textInputValue}
363+
handleSearch={searchInServer}
363364
searchQueryItem={searchQueryItem}
364365
getAdditionalSections={getAdditionalSections}
365366
onListItemPress={onListItemPress}

src/components/Search/index.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout';
1717
import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll';
1818
import useThemeStyles from '@hooks/useThemeStyles';
1919
import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode';
20-
import {search, updateSearchResultsWithTransactionThreadReportID} from '@libs/actions/Search';
20+
import {updateSearchResultsWithTransactionThreadReportID} from '@libs/actions/Search';
2121
import {canUseTouchScreen} from '@libs/DeviceCapabilities';
2222
import Log from '@libs/Log';
2323
import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute';
@@ -48,14 +48,15 @@ import type SearchResults from '@src/types/onyx/SearchResults';
4848
import {useSearchContext} from './SearchContext';
4949
import SearchList from './SearchList';
5050
import SearchScopeProvider from './SearchScopeProvider';
51-
import type {SearchColumnType, SearchQueryJSON, SelectedTransactionInfo, SelectedTransactions, SortOrder} from './types';
51+
import type {SearchColumnType, SearchParams, SearchQueryJSON, SelectedTransactionInfo, SelectedTransactions, SortOrder} from './types';
5252

5353
type SearchProps = {
5454
queryJSON: SearchQueryJSON;
5555
onSearchListScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
5656
contentContainerStyle?: StyleProp<ViewStyle>;
5757
currentSearchResults?: SearchResults;
5858
lastNonEmptySearchResults?: SearchResults;
59+
handleSearch: (value: SearchParams) => void;
5960
};
6061

6162
function mapTransactionItemToSelectedEntry(item: TransactionListItemType): [string, SelectedTransactionInfo] {
@@ -127,7 +128,7 @@ function prepareTransactionsList(item: TransactionListItemType, selectedTransact
127128
};
128129
}
129130

130-
function Search({queryJSON, currentSearchResults, lastNonEmptySearchResults, onSearchListScroll, contentContainerStyle}: SearchProps) {
131+
function Search({queryJSON, currentSearchResults, lastNonEmptySearchResults, onSearchListScroll, contentContainerStyle, handleSearch}: SearchProps) {
131132
const {isOffline} = useNetwork();
132133
const {shouldUseNarrowLayout} = useResponsiveLayout();
133134
const styles = useThemeStyles();
@@ -201,8 +202,8 @@ function Search({queryJSON, currentSearchResults, lastNonEmptySearchResults, onS
201202
return;
202203
}
203204

204-
search({queryJSON, offset});
205-
}, [isOffline, offset, queryJSON]);
205+
handleSearch({queryJSON, offset});
206+
}, [handleSearch, isOffline, offset, queryJSON]);
206207

207208
const {newSearchResultKey, handleSelectionListScroll} = useSearchHighlightAndScroll({
208209
searchResults,

src/components/Search/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,11 @@ type SearchAutocompleteQueryRange = {
154154
value: string;
155155
};
156156

157+
type SearchParams = {
158+
queryJSON: SearchQueryJSON;
159+
offset: number;
160+
};
161+
157162
export type {
158163
SelectedTransactionInfo,
159164
SelectedTransactions,
@@ -178,6 +183,7 @@ export type {
178183
SearchAutocompleteResult,
179184
PaymentData,
180185
SearchAutocompleteQueryRange,
186+
SearchParams,
181187
TableColumnSize,
182188
SearchGroupBy,
183189
};

src/libs/SearchQueryUtils.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,25 @@ function getCurrentSearchQueryJSON() {
827827
return queryJSON;
828828
}
829829

830+
/**
831+
* Extracts the query text without the filter parts.
832+
* This is used to determine if a user's core search terms have changed,
833+
* ignoring any filter modifications.
834+
*
835+
* @param searchQuery - The complete search query string
836+
* @returns The query without filters (core search terms only)
837+
*/
838+
function getQueryWithoutFilters(searchQuery: string) {
839+
const queryJSON = buildSearchQueryJSON(searchQuery);
840+
if (!queryJSON) {
841+
return '';
842+
}
843+
844+
const keywordFilter = queryJSON.flatFilters.find((filter) => filter.key === 'keyword');
845+
846+
return keywordFilter?.filters.map((filter) => filter.value).join(' ') ?? '';
847+
}
848+
830849
/**
831850
* Converts a filter key from old naming (camelCase) to user friendly naming (kebab-case).
832851
*
@@ -869,6 +888,7 @@ export {
869888
sanitizeSearchValue,
870889
getQueryWithUpdatedValues,
871890
getCurrentSearchQueryJSON,
891+
getQueryWithoutFilters,
872892
getUserFriendlyKey,
873893
isDefaultExpensesQuery,
874894
shouldHighlight,

0 commit comments

Comments
 (0)