Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
a9ab1fe
fix: avoid duplicated /SearchForReport calls
martasudol Apr 24, 2025
0b5ea99
Merge branch 'main' into avoid-duplicated-search-for-reports-calls
martasudol Apr 24, 2025
d1f5510
fix: avoid duplicated /SearchForReport calls
martasudol Apr 24, 2025
52b835f
fix: avoid duplicated /SearchForReport calls
martasudol Apr 24, 2025
2e9d962
fix: avoid duplicated /SearchForReport calls
martasudol Apr 24, 2025
712ca22
fix: avoid duplicated /SearchForReport calls
martasudol Apr 24, 2025
fdf27e4
fix: avoid duplicated /SearchForReport calls
martasudol Apr 24, 2025
214c235
fix: avoid duplicated /SearchForReport calls
martasudol Apr 24, 2025
c2849bb
fix: avoid duplicated /SearchForReport calls
martasudol Apr 25, 2025
6d7b55e
Fix bug with /SearchForReports calls on input focus
martasudol Apr 28, 2025
a0c27f6
Moved Search feature documentation to contributingGuides
martasudol Apr 28, 2025
69fe490
Revert docs/_data_.routes.yml to match main
martasudol Apr 28, 2025
fda4689
Fix prettier issues
martasudol Apr 28, 2025
b4a006c
Fix tests
martasudol Apr 28, 2025
65a1c8d
Fix prettier
martasudol Apr 28, 2025
26ad8a4
Fix prettier
martasudol Apr 28, 2025
3c97469
Use getQueryWithoutFilters to rely on query comparison to handle sear…
martasudol Apr 29, 2025
cc05bde
prettier & eslint fixes
martasudol Apr 29, 2025
a75af9c
prettier & eslint fixes
martasudol Apr 29, 2025
2d89229
prettier & eslint fixes
martasudol Apr 29, 2025
9420b43
Refactoring
martasudol Apr 30, 2025
43c5bb1
Prettier
martasudol Apr 30, 2025
641f18c
Merge branch 'main' into avoid-duplicated-search-for-reports-calls
martasudol May 12, 2025
6f94129
build search query optimization
martasudol May 12, 2025
5997f36
PR comments addressed
martasudol May 13, 2025
aa28dc9
prettier fixes
martasudol May 13, 2025
68cd35b
Merge branch 'main' into avoid-duplicated-search-for-reports-calls
martasudol May 14, 2025
83d600e
fix: `/SearchForReports` call on autocomplete result item click
martasudol May 14, 2025
2f7f97f
fix: `/SearchForReports` call missing for narrow resolutions
martasudol May 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions contributingGuides/features/Search.md

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall I am not sure if this will be that useful as search changes often ad we will be updating the help site with similar data and surely we will forget to keep this up to date

Original file line number Diff line number Diff line change
@@ -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.
---
<div id="expensify-classic" markdown="1">

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.
24 changes: 18 additions & 6 deletions src/components/Search/SearchAutocompleteList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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;

Expand Down Expand Up @@ -133,6 +135,7 @@ function SearchRouterItem(props: UserListItemProps<OptionData> | SearchQueryList
function SearchAutocompleteList(
{
autocompleteQueryValue,
handleSearch,
searchQueryItem,
getAdditionalSections,
onListItemPress,
Expand Down Expand Up @@ -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);
Comment thread
martasudol marked this conversation as resolved.
return [parsedQuery, queryWithoutFilters];
}, [autocompleteQueryValue]);

const autocompleteSuggestions = useMemo<AutocompleteItemData[]>(() => {
const autocompleteParsedQuery = parseForAutocomplete(autocompleteQueryValue);
const {autocomplete, ranges = []} = autocompleteParsedQuery ?? {};
const autocompleteKey = autocomplete?.key;
const autocompleteValue = autocomplete?.value ?? '';
Expand Down Expand Up @@ -451,7 +459,7 @@ function SearchAutocompleteList(
}
}
}, [
autocompleteQueryValue,
autocompleteParsedQuery,
tagAutocompleteList,
recentTagsAutocompleteList,
categoryAutocompleteList,
Expand Down Expand Up @@ -514,8 +522,12 @@ function SearchAutocompleteList(
}, [autocompleteQueryValue, filterOptions, searchOptions]);

useEffect(() => {
searchInServer(autocompleteQueryValue.trim());
}, [autocompleteQueryValue]);
if (!handleSearch) {
return;
}

handleSearch(autocompleteQueryWithoutFilters);
Comment thread
martasudol marked this conversation as resolved.
}, [autocompleteQueryWithoutFilters, handleSearch]);

/* Sections generation */
const sections: Array<SectionListDataType<OptionData | SearchQueryItem>> = [];
Expand Down
18 changes: 10 additions & 8 deletions src/components/Search/SearchPageHeader/SearchPageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,24 +28,25 @@ type SearchPageHeaderProps = {
hideSearchRouterList?: () => void;
onSearchRouterFocus?: () => void;
headerButtonsOptions: Array<DropdownOption<SearchHeaderOptionValue>>;
handleSearch: (value: string) => void;
};

type SearchHeaderOptionValue = DeepValueOf<typeof CONST.SEARCH.BULK_ACTION_TYPES> | 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 ?? {});

Expand Down Expand Up @@ -85,6 +86,7 @@ function SearchPageHeader({queryJSON, searchName, searchRouterListVisible, hideS
searchName={searchName}
hideSearchRouterList={hideSearchRouterList}
inputRightComponent={InputRightComponent}
handleSearch={handleSearch}
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -83,9 +84,14 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo
const [isAutocompleteListVisible, setIsAutocompleteListVisible] = useState(false);
const listRef = useRef<SelectionListHandle>(null);
const textInputRef = useRef<AnimatedTextInputRef>(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()) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -279,6 +295,7 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo
<View style={[styles.flex1]}>
<SearchAutocompleteList
autocompleteQueryValue={autocompleteQueryValue}
handleSearch={handleSearchAction}
searchQueryItem={searchQueryItem}
onListItemPress={onListItemPress}
setTextQuery={setTextAndUpdateSelection}
Expand Down Expand Up @@ -345,6 +362,7 @@ function SearchPageHeaderInput({queryJSON, searchRouterListVisible, hideSearchRo
<View style={[styles.mh65vh, !isAutocompleteListVisible && styles.dNone]}>
<SearchAutocompleteList
autocompleteQueryValue={autocompleteQueryValue}
handleSearch={handleSearchAction}
searchQueryItem={searchQueryItem}
onListItemPress={onListItemPress}
setTextQuery={setTextAndUpdateSelection}
Expand Down
3 changes: 2 additions & 1 deletion src/components/Search/SearchRouter/SearchRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import StringUtils from '@libs/StringUtils';
import Navigation from '@navigation/Navigation';
import type {ReportsSplitNavigatorParamList} from '@navigation/types';
import variables from '@styles/variables';
import {navigateToAndOpenReport} from '@userActions/Report';
import {navigateToAndOpenReport, searchInServer} from '@userActions/Report';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
Expand Down Expand Up @@ -360,6 +360,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla
/>
<SearchAutocompleteList
autocompleteQueryValue={autocompleteQueryValue || textInputValue}
handleSearch={searchInServer}
searchQueryItem={searchQueryItem}
getAdditionalSections={getAdditionalSections}
onListItemPress={onListItemPress}
Expand Down
11 changes: 6 additions & 5 deletions src/components/Search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll';
import useThemeStyles from '@hooks/useThemeStyles';
import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode';
import {search, updateSearchResultsWithTransactionThreadReportID} from '@libs/actions/Search';
import {updateSearchResultsWithTransactionThreadReportID} from '@libs/actions/Search';
import {canUseTouchScreen} from '@libs/DeviceCapabilities';
import Log from '@libs/Log';
import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute';
Expand Down Expand Up @@ -48,14 +48,15 @@ import type SearchResults from '@src/types/onyx/SearchResults';
import {useSearchContext} from './SearchContext';
import SearchList from './SearchList';
import SearchScopeProvider from './SearchScopeProvider';
import type {SearchColumnType, SearchQueryJSON, SelectedTransactionInfo, SelectedTransactions, SortOrder} from './types';
import type {SearchColumnType, SearchParams, SearchQueryJSON, SelectedTransactionInfo, SelectedTransactions, SortOrder} from './types';

type SearchProps = {
queryJSON: SearchQueryJSON;
onSearchListScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
contentContainerStyle?: StyleProp<ViewStyle>;
currentSearchResults?: SearchResults;
lastNonEmptySearchResults?: SearchResults;
handleSearch: (value: SearchParams) => void;
};

function mapTransactionItemToSelectedEntry(item: TransactionListItemType): [string, SelectedTransactionInfo] {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/components/Search/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,11 @@ type SearchAutocompleteQueryRange = {
value: string;
};

type SearchParams = {
queryJSON: SearchQueryJSON;
offset: number;
};

export type {
SelectedTransactionInfo,
SelectedTransactions,
Expand All @@ -178,6 +183,7 @@ export type {
SearchAutocompleteResult,
PaymentData,
SearchAutocompleteQueryRange,
SearchParams,
TableColumnSize,
SearchGroupBy,
};
20 changes: 20 additions & 0 deletions src/libs/SearchQueryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
Expand Down Expand Up @@ -869,6 +888,7 @@ export {
sanitizeSearchValue,
getQueryWithUpdatedValues,
getCurrentSearchQueryJSON,
getQueryWithoutFilters,
getUserFriendlyKey,
isDefaultExpensesQuery,
shouldHighlight,
Expand Down
Loading