diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 0811ea02e9d6..39940b34f1ec 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -47,6 +47,8 @@ const ROUTES = { SEARCH_ADVANCED_FILTERS_STATUS: 'search/filters/status', + SEARCH_ADVANCED_FILTERS_CATEGORY: 'search/filters/category', + SEARCH_REPORT: { route: 'search/view/:reportID', getRoute: (reportID: string) => `search/view/${reportID}` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 4047b0a851bc..593e060f4047 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -35,6 +35,7 @@ const SCREENS = { ADVANCED_FILTERS_DATE_RHP: 'Search_Advanced_Filters_Date_RHP', ADVANCED_FILTERS_TYPE_RHP: 'Search_Advanced_Filters_Type_RHP', ADVANCED_FILTERS_STATUS_RHP: 'Search_Advanced_Filters_Status_RHP', + ADVANCED_FILTERS_CATEGORY_RHP: 'Search_Advanced_Filters_Category_RHP', TRANSACTION_HOLD_REASON_RHP: 'Search_Transaction_Hold_Reason_RHP', BOTTOM_TAB: 'Search_Bottom_Tab', }, diff --git a/src/components/SelectionList/SelectableListItem.tsx b/src/components/SelectionList/SelectableListItem.tsx new file mode 100644 index 000000000000..1370a6887a7a --- /dev/null +++ b/src/components/SelectionList/SelectableListItem.tsx @@ -0,0 +1,86 @@ +import React, {useCallback} from 'react'; +import {View} from 'react-native'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import SelectCircle from '@components/SelectCircle'; +import TextWithTooltip from '@components/TextWithTooltip'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import BaseListItem from './BaseListItem'; +import type {InviteMemberListItemProps, ListItem} from './types'; + +function SelectableListItem({ + item, + isFocused, + showTooltip, + isDisabled, + canSelectMultiple, + onSelectRow, + onCheckboxPress, + onDismissError, + onFocus, + shouldSyncFocus, +}: InviteMemberListItemProps) { + const styles = useThemeStyles(); + const handleCheckboxPress = useCallback(() => { + if (onCheckboxPress) { + onCheckboxPress(item); + } else { + onSelectRow(item); + } + }, [item, onCheckboxPress, onSelectRow]); + + return ( + + <> + + + + + + {!!item.rightElement && item.rightElement} + {canSelectMultiple && !item.isDisabled && ( + + + + )} + + + ); +} + +SelectableListItem.displayName = 'SelectableListItem'; + +export default SelectableListItem; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index f3fc8accb83f..1d969a002f8b 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -514,6 +514,7 @@ const SearchAdvancedFiltersModalStackNavigator = createModalStackNavigator require('../../../../pages/Search/SearchFiltersDatePage').default, [SCREENS.SEARCH.ADVANCED_FILTERS_TYPE_RHP]: () => require('../../../../pages/Search/SearchFiltersTypePage').default, [SCREENS.SEARCH.ADVANCED_FILTERS_STATUS_RHP]: () => require('../../../../pages/Search/SearchFiltersStatusPage').default, + [SCREENS.SEARCH.ADVANCED_FILTERS_CATEGORY_RHP]: () => require('../../../../pages/Search/SearchFiltersCategoryPage').default, }); const RestrictedActionModalStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index f2d1bfb36d7e..db01a6248013 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1017,6 +1017,7 @@ const config: LinkingOptions['config'] = { [SCREENS.SEARCH.ADVANCED_FILTERS_DATE_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_DATE, [SCREENS.SEARCH.ADVANCED_FILTERS_TYPE_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_TYPE, [SCREENS.SEARCH.ADVANCED_FILTERS_STATUS_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_STATUS, + [SCREENS.SEARCH.ADVANCED_FILTERS_CATEGORY_RHP]: ROUTES.SEARCH_ADVANCED_FILTERS_CATEGORY, }, }, [SCREENS.RIGHT_MODAL.RESTRICTED_ACTION]: { diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index 367f414eb53d..6710e2df5a47 100644 --- a/src/libs/SearchUtils.ts +++ b/src/libs/SearchUtils.ts @@ -387,6 +387,13 @@ function buildDateFilterQuery(filterValues: Partial) return dateFilter; } +function sanitizeString(str: string) { + if (str.includes(' ')) { + return `"${str}"`; + } + return str; +} + /** * Given object with chosen search filters builds correct query string from them */ @@ -402,6 +409,11 @@ function buildQueryStringFromFilters(filterValues: Partial, fiel return dateValue; } + if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY && filters[fieldName]) { + const categories = filters[fieldName] ?? []; + return categories.join(', '); + } + // Todo Once all Advanced filters are implemented this line can be cleaned up. See: https://github.com/Expensify/App/issues/45026 // @ts-expect-error this property access is temporarily an error, because not every SYNTAX_FILTER_KEYS is handled by form. // When all filters are updated here: src/types/form/SearchAdvancedFiltersForm.ts this line comment + type cast can be removed. @@ -68,6 +73,11 @@ function AdvancedSearchFilters() { description: 'common.date' as const, route: ROUTES.SEARCH_ADVANCED_FILTERS_DATE, }, + { + title: getFilterDisplayTitle(searchAdvancedFilters, CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY, translate), + description: 'common.category' as const, + route: ROUTES.SEARCH_ADVANCED_FILTERS_CATEGORY, + }, ], [searchAdvancedFilters, translate], ); diff --git a/src/pages/Search/SearchFiltersCategoryPage.tsx b/src/pages/Search/SearchFiltersCategoryPage.tsx new file mode 100644 index 000000000000..abc1be6e4db4 --- /dev/null +++ b/src/pages/Search/SearchFiltersCategoryPage.tsx @@ -0,0 +1,154 @@ +import React, {useCallback, useMemo, useState} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import Button from '@components/Button'; +import type {FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import SelectableListItem from '@components/SelectionList/SelectableListItem'; +import useDebouncedState from '@hooks/useDebouncedState'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import localeCompare from '@libs/LocaleCompare'; +import type {CategorySection} from '@libs/OptionsListUtils'; +import type {OptionData} from '@libs/ReportUtils'; +import Navigation from '@navigation/Navigation'; +import * as SearchActions from '@userActions/Search'; +import ONYXKEYS from '@src/ONYXKEYS'; + +function SearchFiltersCategoryPage() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const [noResultsFound, setNoResultsFound] = useState(false); + + const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); + const currentCategories = searchAdvancedFiltersForm?.category; + const [newCategories, setNewCategories] = useState(currentCategories ?? []); + const policyID = searchAdvancedFiltersForm?.policyID ?? '-1'; + + const [allPolicyIDCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); + const singlePolicyCategories = allPolicyIDCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]; + + const categoryNames = useMemo(() => { + let categories: string[] = []; + if (!singlePolicyCategories) { + categories = Object.values(allPolicyIDCategories ?? {}) + .map((policyCategories) => Object.values(policyCategories ?? {}).map((category) => category.name)) + .flat(); + } else { + categories = Object.values(singlePolicyCategories ?? {}).map((value) => value.name); + } + + return [...new Set(categories)]; + }, [allPolicyIDCategories, singlePolicyCategories]); + + const sections = useMemo(() => { + const newSections: CategorySection[] = []; + const chosenCategories = newCategories + .filter((category) => category.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) + .sort((a, b) => localeCompare(a, b)) + .map((name) => ({ + text: name, + keyForList: name, + isSelected: newCategories?.includes(name) ?? false, + })); + const remainingCategories = categoryNames + .filter((category) => newCategories.includes(category) === false) + .filter((category) => category.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) + .sort((a, b) => localeCompare(a, b)) + .map((name) => ({ + text: name, + keyForList: name, + isSelected: newCategories?.includes(name) ?? false, + })); + if (chosenCategories.length === 0 && remainingCategories.length === 0) { + setNoResultsFound(true); + } else { + setNoResultsFound(false); + } + newSections.push({ + title: undefined, + data: chosenCategories, + shouldShow: chosenCategories.length > 0, + }); + newSections.push({ + title: translate('common.category'), + data: remainingCategories, + shouldShow: remainingCategories.length > 0, + }); + return newSections; + }, [categoryNames, newCategories, translate, debouncedSearchTerm]); + + const updateCategory = useCallback((values: Partial>) => { + SearchActions.updateAdvancedFilters(values); + }, []); + + const handleConfirmSelection = useCallback(() => { + updateCategory({ + category: newCategories.sort((a, b) => localeCompare(a, b)), + }); + Navigation.goBack(); + }, [newCategories, updateCategory]); + + const updateNewCategories = useCallback( + (item: Partial) => { + if (!item.text) { + return; + } + if (item.isSelected) { + setNewCategories(newCategories?.filter((category) => category !== item.text)); + } else { + setNewCategories([...(newCategories ?? []), item.text]); + } + }, + [newCategories], + ); + + const footerContent = useMemo( + () => ( +