diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 4789213e0fff0..5147c21896808 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8595,6 +8595,7 @@ const CONST = { 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', @@ -8623,6 +8624,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/ONYXKEYS.ts b/src/ONYXKEYS.ts index cc6a4747174a2..132a9f85a8a40 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -997,6 +997,8 @@ const ONYXKEYS = { SAGE_INTACCT_DIMENSION_TYPE_FORM_DRAFT: 'sageIntacctDimensionTypeFormDraft', SEARCH_ADVANCED_FILTERS_FORM: 'searchAdvancedFiltersForm', SEARCH_ADVANCED_FILTERS_FORM_DRAFT: 'searchAdvancedFiltersFormDraft', + SEARCH_SAVE_FORM: 'searchSaveForm', + SEARCH_SAVE_FORM_DRAFT: 'searchSaveFormDraft', SEARCH_SAVED_SEARCH_RENAME_FORM: 'searchSavedSearchRenameForm', SEARCH_SAVED_SEARCH_RENAME_FORM_DRAFT: 'searchSavedSearchRenameFormDraft', TEXT_PICKER_MODAL_FORM: 'textPickerModalForm', @@ -1162,6 +1164,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.RULES_MAX_EXPENSE_AGE_FORM]: FormTypes.RulesMaxExpenseAgeForm; [ONYXKEYS.FORMS.RULES_CUSTOM_FORM]: FormTypes.RulesCustomForm; [ONYXKEYS.FORMS.SEARCH_SAVED_SEARCH_RENAME_FORM]: FormTypes.SearchSavedSearchRenameForm; + [ONYXKEYS.FORMS.SEARCH_SAVE_FORM]: FormTypes.SearchSaveForm; [ONYXKEYS.FORMS.DEBUG_DETAILS_FORM]: FormTypes.DebugReportForm | FormTypes.DebugReportActionForm | FormTypes.DebugTransactionForm | FormTypes.DebugTransactionViolationForm; [ONYXKEYS.FORMS.ONBOARDING_WORK_EMAIL_FORM]: FormTypes.OnboardingWorkEmailForm; [ONYXKEYS.FORMS.MERGE_ACCOUNT_DETAILS_FORM]: FormTypes.MergeAccountDetailsForm; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 952a920d1f6e0..18197125ecd0d 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -139,6 +139,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 51fffec8051df..491c38eb51e74 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -300,6 +300,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..4cbc8bbaefca1 --- /dev/null +++ b/src/components/Search/FilterDropdowns/DisplayPopup.tsx @@ -0,0 +1,205 @@ +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 type {SearchQueryJSON} from '@components/Search/types'; +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} from '@libs/SearchQueryUtils'; +import {getGroupBySections, getSearchColumnTranslationKey, getViewOptions} from '@libs/SearchUIUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {SearchAdvancedFiltersForm} from '@src/types/form'; +import type {SearchResults} from '@src/types/onyx'; +import {getEmptyObject} from '@src/types/utils/EmptyObject'; +import GroupByPopup from './GroupByPopup'; +import SingleSelectPopup from './SingleSelectPopup'; +import SortByPopup from './SortByPopup'; +import TextInputPopup from './TextInputPopup'; + +type DisplayPopupProps = { + queryJSON: SearchQueryJSON; + searchResults: OnyxEntry; + closeOverlay: () => void; + onSort: () => void; +}; + +function DisplayPopup({queryJSON, searchResults, closeOverlay, onSort}: DisplayPopupProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {shouldUseNarrowLayout, isLargeScreenWidth} = useResponsiveLayout(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Columns']); + const [searchAdvancedFilters = getEmptyObject()] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); + const [selectedDisplayFilter, setSelectedDisplayFilter] = 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; + const isTripType = queryJSON.type === CONST.SEARCH.DATA_TYPES.TRIP; + return ( + + setSelectedDisplayFilter(CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_BY)} + /> + {(isExpenseType || isTripType) && ( + setSelectedDisplayFilter(CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY)} + /> + )} + {isExpenseType && !!groupByValue && ( + setSelectedDisplayFilter(CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW)} + /> + )} + {isExpenseType && ( + setSelectedDisplayFilter(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]: ( + setSelectedDisplayFilter(null)} + /> + ), + [CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY]: ( + setSelectedDisplayFilter(null)} + onChange={(item) => { + const newValue = item?.value; + if (!newValue) { + updateFilterForm({groupBy: undefined, groupCurrency: undefined}); + } else { + updateFilterForm({groupBy: newValue}); + } + }} + /> + ), + [CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW]: ( + setSelectedDisplayFilter(null)} + onChange={(item) => updateFilterForm({view: item?.value ?? CONST.SEARCH.VIEW.TABLE})} + /> + ), + [CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT]: ( + setSelectedDisplayFilter(null)} + onChange={(value) => updateFilterForm({limit: value})} + /> + ), + }; + + return ( + + setSelectedDisplayFilter(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/GroupByPopup.tsx b/src/components/Search/FilterDropdowns/GroupByPopup.tsx index 949152ff8dae7..a12e714ad25b5 100644 --- a/src/components/Search/FilterDropdowns/GroupByPopup.tsx +++ b/src/components/Search/FilterDropdowns/GroupByPopup.tsx @@ -1,5 +1,6 @@ import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; import Button from '@components/Button'; import type {SearchGroupBy} from '@components/Search/types'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; @@ -28,6 +29,8 @@ type GroupByPopupProps = { /** The currently selected item */ value: GroupByPopupItem | null; + style?: StyleProp; + /** Function to call to close the overlay when changes are applied */ closeOverlay: () => void; @@ -35,7 +38,7 @@ type GroupByPopupProps = { onChange: (item: GroupByPopupItem | null) => void; }; -function GroupByPopup({label, value, sections, closeOverlay, onChange}: GroupByPopupProps) { +function GroupByPopup({label, value, sections, style, closeOverlay, onChange}: GroupByPopupProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -83,7 +86,7 @@ function GroupByPopup({label, value, sections, closeOverlay, onChange}: GroupByP }, [closeOverlay, onChange]); return ( - + {isSmallScreenWidth && !!label && {label}} diff --git a/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx b/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx index 40d836d2af625..7e98978530b44 100644 --- a/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx +++ b/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx @@ -1,5 +1,6 @@ import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; import Button from '@components/Button'; import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; @@ -42,11 +43,24 @@ type SingleSelectPopupProps = { /** The default value to set when reset is clicked */ defaultValue?: string; + style?: StyleProp; + /** Custom styles for the SelectionList */ selectionListStyle?: SelectionListStyle; }; -function SingleSelectPopup({label, value, items, closeOverlay, onChange, isSearchable, searchPlaceholder, defaultValue, selectionListStyle}: SingleSelectPopupProps) { +function SingleSelectPopup({ + label, + value, + items, + closeOverlay, + onChange, + isSearchable, + searchPlaceholder, + defaultValue, + style, + selectionListStyle, +}: SingleSelectPopupProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -117,7 +131,7 @@ function SingleSelectPopup({label, value, items, closeOverlay, const shouldShowLabel = isSmallScreenWidth && !!label; return ( - + {shouldShowLabel && {label}} diff --git a/src/components/Search/FilterDropdowns/SortByPopup.tsx b/src/components/Search/FilterDropdowns/SortByPopup.tsx new file mode 100644 index 0000000000000..3d1b3ecc796e4 --- /dev/null +++ b/src/components/Search/FilterDropdowns/SortByPopup.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; +import type {SearchColumnType, SearchGroupBy, SearchQueryJSON} from '@components/Search/types'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +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 {buildSearchQueryString} from '@libs/SearchQueryUtils'; +import {getColumnsToShow, getSearchColumnTranslationKey, getSortByOptions} from '@libs/SearchUIUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; +import type {SearchResults} from '@src/types/onyx'; +import SingleSelectPopup from './SingleSelectPopup'; +import type {SingleSelectItem} from './SingleSelectPopup'; + +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 styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + 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); + }} + /> + ); +} + +export default SortByPopup; diff --git a/src/components/Search/FilterDropdowns/TextInputPopup.tsx b/src/components/Search/FilterDropdowns/TextInputPopup.tsx new file mode 100644 index 0000000000000..b3570ce3fbf03 --- /dev/null +++ b/src/components/Search/FilterDropdowns/TextInputPopup.tsx @@ -0,0 +1,67 @@ +import React, {useState} from 'react'; +import {View} from 'react-native'; +import type {StyleProp, ViewStyle} 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 TextInputPopupProps = { + style?: StyleProp; + defaultValue: string; + placeholder?: string; + closeOverlay: () => void; + onChange: (value: string) => void; +}; + +function TextInputPopup({style, defaultValue, placeholder, closeOverlay, onChange}: TextInputPopupProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + const [value, setValue] = useState(defaultValue); + + const applyChanges = () => { + onChange(value); + closeOverlay(); + }; + + const resetChanges = () => { + onChange(''); + closeOverlay(); + }; + + return ( + + + +