From 55b608d7da02844fdd13129791a31f8e8740779a Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Tue, 17 Mar 2026 14:35:43 +0800 Subject: [PATCH 01/48] revamp search page actions bar --- src/CONST/index.ts | 4 +- src/ROUTES.ts | 1 + src/SCREENS.ts | 1 + .../MoneyRequestReportTransactionList.tsx | 2 +- src/components/Navigation/SearchSidebar.tsx | 4 +- .../Search/FilterDropdowns/DisplayPopup.tsx | 254 +++++++ .../Search/FilterDropdowns/DropdownButton.tsx | 2 +- .../Search/FilterDropdowns/TextInputPopup.tsx | 62 ++ .../Search/SearchAutocompleteInput.tsx | 119 ++-- src/components/Search/SearchChartView.tsx | 7 +- src/components/Search/SearchContext.tsx | 10 +- src/components/Search/SearchList/index.tsx | 2 +- ...n.tsx => SearchActionsBarCreateButton.tsx} | 8 +- .../SearchActionsBarNarrow.tsx | 78 +++ .../SearchPageHeader/SearchActionsBarWide.tsx | 142 ++++ .../SearchAdvanceFiltersButton.tsx | 35 + .../SearchDisplayDropdownButton.tsx | 40 ++ .../SearchPageHeader/SearchFiltersBar.tsx | 28 - .../SearchFiltersBarNarrow.tsx | 107 --- .../SearchPageHeader/SearchFiltersBarWide.tsx | 105 --- .../SearchPageHeader/SearchPageHeader.tsx | 39 -- .../SearchPageHeaderNarrow.tsx | 31 + .../SearchPageHeader/SearchPageHeaderWide.tsx | 27 + .../SearchPageInputNarrow.tsx | 276 ++++++++ ...eaderInput.tsx => SearchPageInputWide.tsx} | 212 ++---- .../SearchPageHeader/SearchSaveButton.tsx | 22 + .../SearchPageHeader/SearchTypeMenuNarrow.tsx | 92 +++ .../SearchTypeMenuPopover.tsx | 54 -- .../SearchPageHeader/useSearchActionsBar.tsx | 430 ++++++++++++ .../SearchPageHeader/useSearchFiltersBar.tsx | 663 ------------------ .../Search/hooks/useFilterFeedValue.tsx | 23 + .../Search/hooks/useFilterFromValue.tsx | 20 + .../Search/hooks/useFilterWorkspaceValue.tsx | 27 + src/components/Search/index.tsx | 17 +- src/components/Search/selectors/Search.ts | 8 + src/components/Search/types.ts | 4 +- ...Skeleton.tsx => SearchActionsSkeleton.tsx} | 10 +- src/hooks/useSearchBulkActions.ts | 4 +- src/hooks/useSearchFilterSync.ts | 2 +- src/hooks/useSearchTypeMenu.tsx | 244 ------- src/languages/en.ts | 9 +- src/languages/es.ts | 11 +- .../Navigators/RightModalNavigator.tsx | 5 + src/libs/Navigation/linkingConfig/config.ts | 1 + src/libs/Navigation/types.ts | 1 + src/libs/SearchQueryUtils.ts | 6 +- src/libs/SearchUIUtils.ts | 201 +++++- src/pages/Search/AdvancedSearchFilters.tsx | 6 +- .../SearchFiltersGroupByPage.tsx | 2 +- src/pages/Search/SearchPage.tsx | 1 + src/pages/Search/SearchPageNarrow.tsx | 79 ++- src/pages/Search/SearchPageWide.tsx | 15 +- src/pages/Search/SearchSavePage.tsx | 139 ++++ src/pages/Search/SearchTypeMenuItem.tsx | 10 +- ...rchTypeMenu.tsx => SearchTypeMenuWide.tsx} | 36 +- src/pages/Search/SuggestedSearchSkeleton.tsx | 2 +- src/styles/index.ts | 48 +- src/styles/variables.ts | 12 +- src/types/form/SearchAdvancedFiltersForm.ts | 11 +- .../useFilterFormValues.perf-test.tsx | 2 +- tests/ui/CategoryListItemHeaderTest.tsx | 4 +- tests/ui/MerchantListItemHeaderTest.tsx | 4 +- tests/ui/MonthListItemHeaderTest.tsx | 4 +- tests/ui/ReportListItemHeaderTest.tsx | 4 +- tests/ui/WeekListItemHeaderTest.tsx | 4 +- tests/ui/YearListItemHeaderTest.tsx | 4 +- ...x => SearchActionsBarCreateButtonTest.tsx} | 6 +- tests/unit/hooks/useFilterFormValues.test.ts | 2 +- 68 files changed, 2230 insertions(+), 1615 deletions(-) create mode 100644 src/components/Search/FilterDropdowns/DisplayPopup.tsx create mode 100644 src/components/Search/FilterDropdowns/TextInputPopup.tsx rename src/components/Search/SearchPageHeader/{SearchFiltersBarCreateButton.tsx => SearchActionsBarCreateButton.tsx} (98%) create mode 100644 src/components/Search/SearchPageHeader/SearchActionsBarNarrow.tsx create mode 100644 src/components/Search/SearchPageHeader/SearchActionsBarWide.tsx create mode 100644 src/components/Search/SearchPageHeader/SearchAdvanceFiltersButton.tsx create mode 100644 src/components/Search/SearchPageHeader/SearchDisplayDropdownButton.tsx delete mode 100644 src/components/Search/SearchPageHeader/SearchFiltersBar.tsx delete mode 100644 src/components/Search/SearchPageHeader/SearchFiltersBarNarrow.tsx delete mode 100644 src/components/Search/SearchPageHeader/SearchFiltersBarWide.tsx delete mode 100644 src/components/Search/SearchPageHeader/SearchPageHeader.tsx create mode 100644 src/components/Search/SearchPageHeader/SearchPageHeaderNarrow.tsx create mode 100644 src/components/Search/SearchPageHeader/SearchPageHeaderWide.tsx create mode 100644 src/components/Search/SearchPageHeader/SearchPageInputNarrow.tsx rename src/components/Search/SearchPageHeader/{SearchPageHeaderInput.tsx => SearchPageInputWide.tsx} (57%) create mode 100644 src/components/Search/SearchPageHeader/SearchSaveButton.tsx create mode 100644 src/components/Search/SearchPageHeader/SearchTypeMenuNarrow.tsx delete mode 100644 src/components/Search/SearchPageHeader/SearchTypeMenuPopover.tsx create mode 100644 src/components/Search/SearchPageHeader/useSearchActionsBar.tsx delete mode 100644 src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx create mode 100644 src/components/Search/hooks/useFilterFeedValue.tsx create mode 100644 src/components/Search/hooks/useFilterFromValue.tsx create mode 100644 src/components/Search/hooks/useFilterWorkspaceValue.tsx create mode 100644 src/components/Search/selectors/Search.ts rename src/components/Skeletons/{SearchFiltersSkeleton.tsx => SearchActionsSkeleton.tsx} (88%) delete mode 100644 src/hooks/useSearchTypeMenu.tsx create mode 100644 src/pages/Search/SearchSavePage.tsx rename src/pages/Search/{SearchTypeMenu.tsx => SearchTypeMenuWide.tsx} (78%) rename tests/ui/components/{SearchFiltersBarCreateButtonTest.tsx => SearchActionsBarCreateButtonTest.tsx} (98%) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index d3ffa21729877..64f61a66dba84 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8556,7 +8556,7 @@ const CONST = { TRANSACTION_LIST_ITEM: 'Search-TransactionListItem', REPORT_EXPAND_COLLAPSE: 'Search-ReportExpandCollapse', SELECT_ALL_BUTTON: 'Search-SelectAllButton', - TYPE_MENU_BUTTON: 'Search-TypeMenuButton', + FILTER_DISPLAY: 'Search-FilterDisplay', FILTER_TYPE: 'Search-FilterType', FILTER_STATUS: 'Search-FilterStatus', FILTER_DATE: 'Search-FilterDate', @@ -8585,6 +8585,8 @@ const CONST = { FILTER_POPUP_APPLY_SINGLE_SELECT: 'Search-FilterPopupApplySingleSelect', FILTER_POPUP_RESET_MULTI_SELECT: 'Search-FilterPopupResetMultiSelect', FILTER_POPUP_APPLY_MULTI_SELECT: 'Search-FilterPopupApplyMultiSelect', + FILTER_POPUP_RESET_TEXT_INPUT: 'Search-FilterPopupResetTextInput', + FILTER_POPUP_APPLY_TEXT_INPUT: 'Search-FilterPopupApplyTextInput', FILTER_POPUP_RESET_DATE: 'Search-FilterPopupResetDate', FILTER_POPUP_APPLY_DATE: 'Search-FilterPopupApplyDate', FILTER_POPUP_RESET_USER: 'Search-FilterPopupResetUser', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index c1cd30528c31e..7da2b523190a9 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -131,6 +131,7 @@ const ROUTES = { }, }, SEARCH_ROOT_VERIFY_ACCOUNT: `search/${VERIFY_ACCOUNT}`, + 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=${jsonQuery}` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 6c2599a91dc50..56073d191ccb1 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -298,6 +298,7 @@ const SCREENS = { SEARCH_COLUMNS: 'SearchColumns', SEARCH_ADVANCED_FILTERS: 'SearchAdvancedFilters', + SEARCH_SAVE: 'SearchSave', SEARCH_SAVED_SEARCH: 'SearchSavedSearch', SETTINGS_CATEGORIES: 'SettingsCategories', SETTINGS_TAGS: 'SettingsTags', diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 1239c32f21e84..68f35d0e5261b 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -670,7 +670,7 @@ function MoneyRequestReportTransactionList({ {shouldShowGroupedTransactions && ( diff --git a/src/components/Navigation/SearchSidebar.tsx b/src/components/Navigation/SearchSidebar.tsx index 806b7c5148c88..37d789ef0c75a 100644 --- a/src/components/Navigation/SearchSidebar.tsx +++ b/src/components/Navigation/SearchSidebar.tsx @@ -8,7 +8,7 @@ import useNetwork from '@hooks/useNetwork'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import type {PlatformStackNavigationState} from '@libs/Navigation/PlatformStackNavigation/types'; -import SearchTypeMenu from '@pages/Search/SearchTypeMenu'; +import SearchTypeMenuWide from '@pages/Search/SearchTypeMenuWide'; import SCREENS from '@src/SCREENS'; import NavigationTabBar from './NavigationTabBar'; import NAVIGATION_TABS from './NavigationTabBar/NAVIGATION_TABS'; @@ -55,7 +55,7 @@ function SearchSidebar({state}: SearchSidebarProps) { shouldDisplaySearch={false} shouldDisplayHelpButton={false} /> - + diff --git a/src/components/Search/FilterDropdowns/DisplayPopup.tsx b/src/components/Search/FilterDropdowns/DisplayPopup.tsx new file mode 100644 index 0000000000000..fcd3db33d4413 --- /dev/null +++ b/src/components/Search/FilterDropdowns/DisplayPopup.tsx @@ -0,0 +1,254 @@ +import React, {useState} from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import MenuItem from '@components/MenuItem'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {close} from '@libs/actions/Modal'; +import Navigation from '@libs/Navigation/Navigation'; +import {buildFilterQueryWithSortDefaults, buildSearchQueryString} from '@libs/SearchQueryUtils'; +import {getColumnsToShow, getGroupBySections, getSearchColumnTranslationKey, getSortByOptions, getViewOptions} from '@libs/SearchUIUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; +import type {SearchAdvancedFiltersForm} from '@src/types/form'; +import type {SearchResults} from '@src/types/onyx'; +import {getEmptyObject} from '@src/types/utils/EmptyObject'; +import {useSearchActionsContext, useSearchStateContext} from '../SearchContext'; +import type {SearchColumnType, SearchGroupBy, SearchQueryJSON} from '../types'; +import GroupByPopup from './GroupByPopup'; +import SingleSelectPopup from './SingleSelectPopup'; +import type {SingleSelectItem} from './SingleSelectPopup'; +import TextInputPopup from './TextInputPopup'; + +type DisplayPopupProps = { + queryJSON: SearchQueryJSON; + searchResults: OnyxEntry; + closeOverlay: () => void; + onSort: () => void; +}; + +type SortByPopupProps = { + searchResults: OnyxEntry; + queryJSON: SearchQueryJSON; + groupBy: SingleSelectItem | null; + onSort: () => void; + closeOverlay: () => void; +}; + +function SortByPopup({searchResults, queryJSON, groupBy, onSort, closeOverlay}: SortByPopupProps) { + const {translate} = useLocalize(); + const {accountID} = useCurrentUserPersonalDetails(); + const {shouldUseLiveData} = useSearchStateContext(); + const {clearSelectedTransactions} = useSearchActionsContext(); + const [visibleColumns] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: columnsSelector}); + const searchDataType = shouldUseLiveData ? CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT : searchResults?.search?.type; + const currentColumns = !searchResults?.data ? [] : getColumnsToShow(accountID, searchResults?.data, visibleColumns, false, searchDataType, groupBy?.value); + const sortableColumns = getSortByOptions(currentColumns, translate); + const sortBy = {text: translate(getSearchColumnTranslationKey(queryJSON.sortBy)), value: queryJSON.sortBy}; + + const onSortChange = (column: SearchColumnType) => { + clearSelectedTransactions(); + const newQuery = buildSearchQueryString({ + ...queryJSON, + sortBy: column, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + }); + onSort(); + // We want to explicitly clear stale rawQuery since it's only used for manually typed-in queries. + close(() => { + Navigation.setParams({q: newQuery, rawQuery: undefined}); + }); + }; + + return ( + { + if (!item) { + return; + } + onSortChange(item.value); + }} + /> + ); +} + +function DisplayPopup({queryJSON, searchResults, closeOverlay, onSort}: DisplayPopupProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {isSmallScreenWidth, isLargeScreenWidth} = useResponsiveLayout(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Columns']); + const [searchAdvancedFilters = getEmptyObject()] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); + const [selectedDisplayFilter, setSelectedDisplayModifier] = useState< + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_BY + | null + >(null); + + const groupBySections = getGroupBySections(translate); + const groupBy = groupBySections.flatMap((section) => section.options).find((option) => option.value === queryJSON.groupBy) ?? null; + const viewOptions = getViewOptions(translate); + const view = viewOptions.find((option) => option.value === queryJSON.view) ?? viewOptions.at(0) ?? null; + const shouldShowColumnsButton = isLargeScreenWidth && (queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE || queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT); + + const sortByValue = queryJSON.sortBy; + const groupByValue = searchAdvancedFilters[CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY]; + const viewValue = searchAdvancedFilters[CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW]; + const limitValue = searchAdvancedFilters[CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT]; + + if (!selectedDisplayFilter) { + const openSearchColumns = () => { + Navigation.navigate(ROUTES.SEARCH_COLUMNS); + }; + + const isExpenseType = queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE; + return ( + + setSelectedDisplayModifier(CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_BY)} + /> + {isExpenseType && ( + setSelectedDisplayModifier(CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY)} + /> + )} + {isExpenseType && !!groupByValue && ( + setSelectedDisplayModifier(CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW)} + /> + )} + {isExpenseType && ( + setSelectedDisplayModifier(CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT)} + /> + )} + {shouldShowColumnsButton && ( + { + closeOverlay(); + openSearchColumns(); + }} + sentryLabel={CONST.SENTRY_LABEL.SEARCH.COLUMNS_BUTTON} + /> + )} + + ); + } + + const updateFilterForm = (values: Partial) => { + const updatedFilterFormValues: Partial = { + ...searchAdvancedFilters, + ...values, + }; + + if (updatedFilterFormValues.groupBy !== searchAdvancedFilters.groupBy) { + updatedFilterFormValues.columns = []; + } + + const queryString = + buildFilterQueryWithSortDefaults( + updatedFilterFormValues, + {view: searchAdvancedFilters.view, groupBy: searchAdvancedFilters.groupBy}, + {sortBy: queryJSON.sortBy, sortOrder: queryJSON.sortOrder, limit: queryJSON.limit}, + ) ?? ''; + if (!queryString) { + return; + } + + close(() => Navigation.setParams({q: queryString, rawQuery: undefined})); + }; + + const subtitle = { + [CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_BY]: translate('search.display.sortBy'), + [CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY]: translate('search.display.groupBy'), + [CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW]: translate('search.view.label'), + [CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT]: translate('search.display.limitResults'), + }; + + const subPopup = { + [CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_BY]: ( + setSelectedDisplayModifier(null)} + /> + ), + [CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY]: ( + setSelectedDisplayModifier(null)} + onChange={(item) => { + const newValue = item?.value; + if (!newValue) { + updateFilterForm({groupBy: undefined, groupCurrency: undefined}); + } else { + updateFilterForm({groupBy: newValue}); + } + }} + /> + ), + [CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW]: ( + setSelectedDisplayModifier(null)} + onChange={(item) => updateFilterForm({view: item?.value ?? CONST.SEARCH.VIEW.TABLE})} + /> + ), + [CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT]: ( + setSelectedDisplayModifier(null)} + onChange={(value) => updateFilterForm({limit: value})} + /> + ), + }; + + return ( + + setSelectedDisplayModifier(null)} + /> + {subPopup[selectedDisplayFilter]} + + ); +} + +export default DisplayPopup; diff --git a/src/components/Search/FilterDropdowns/DropdownButton.tsx b/src/components/Search/FilterDropdowns/DropdownButton.tsx index f0012ea465e7d..b1c66354c7189 100644 --- a/src/components/Search/FilterDropdowns/DropdownButton.tsx +++ b/src/components/Search/FilterDropdowns/DropdownButton.tsx @@ -183,5 +183,5 @@ function DropdownButton({label, value, viewportOffsetTop, PopoverComponent, medi ); } -export type {PopoverComponentProps}; +export type {PopoverComponentProps, DropdownButtonProps}; export default withViewportOffsetTop(DropdownButton); diff --git a/src/components/Search/FilterDropdowns/TextInputPopup.tsx b/src/components/Search/FilterDropdowns/TextInputPopup.tsx new file mode 100644 index 0000000000000..46c7293332b81 --- /dev/null +++ b/src/components/Search/FilterDropdowns/TextInputPopup.tsx @@ -0,0 +1,62 @@ +import React, {useState} from 'react'; +import {View} from 'react-native'; +import Button from '@components/Button'; +import TextInput from '@components/TextInput'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; + +type SingleSelectPopupProps = { + value: string; + closeOverlay: () => void; + onChange: (value: string) => void; + placeholder?: string; +}; + +function TextInputPopup({value, closeOverlay, onChange, placeholder}: SingleSelectPopupProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + const [_value, setValue] = useState(value); + + const applyChanges = () => { + onChange(_value); + closeOverlay(); + }; + + const resetChanges = () => { + onChange(''); + closeOverlay(); + }; + + return ( + + + +