diff --git a/src/components/Search/FilterComponents/AdvancedFilters/FilterList.tsx b/src/components/Search/FilterComponents/AdvancedFilters/FilterList.tsx index f629cdc74189..60c2957ba989 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/FilterList.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/FilterList.tsx @@ -1,10 +1,12 @@ import Icon from '@components/Icon'; import {PressableWithFeedback} from '@components/Pressable'; import ScrollView from '@components/ScrollView'; +import CompactSearchBar from '@components/SearchBar/CompactSearchBar'; import SpacerView from '@components/SpacerView'; import Text from '@components/Text'; +import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; -import useAdvancedSearchFilters from '@hooks/useAdvancedSearchFilters'; +import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -19,6 +21,7 @@ import variables from '@styles/variables'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; +import type {ReactNode, Ref} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import React from 'react'; @@ -37,6 +40,20 @@ type FilterListProps = FilterItemCallbacks & { style?: StyleProp; contentContainerStyle?: StyleProp; + /** The filters to list, in sections. */ + filters: Array>; + + /** Text in the search input above the list. */ + searchValue: string; + + onSearchValueChange: (searchValue: string) => void; + + /** Ref to the search input above the list. */ + searchInputRef?: Ref; + + /** Content at the top of the list, scrolled with its rows. Pass a falsy value when the search hides it, so the list knows nothing is listed. */ + headerContent?: ReactNode; + /** Called as the cursor travels over the list. Web only - it reports the movement inside a row, which `onHoverIn` only reports entering. */ onPointerMove?: (event: {clientX: number; clientY: number}) => void; @@ -108,40 +125,79 @@ function FilterItem({filterKey, isSelected, type, onPress, onHoverIn, onFocus}: ); } -function FilterList({type, selectedFilter, style, contentContainerStyle, onHoverIn, onFocus, onPress, onPointerMove, onPointerLeave}: FilterListProps) { +function FilterList({ + type, + selectedFilter, + style, + contentContainerStyle, + filters, + searchValue, + onSearchValueChange, + searchInputRef, + headerContent, + onHoverIn, + onFocus, + onPress, + onPointerMove, + onPointerLeave, +}: FilterListProps) { const styles = useThemeStyles(); - const typeFiltersKeys = useAdvancedSearchFilters(type); + const {translate} = useLocalize(); + const noResultsFoundText = translate('common.noResultsFound'); + const shouldShowNoResultsFound = filters.length === 0 && !headerContent; + + useDebouncedAccessibilityAnnouncement(noResultsFoundText, shouldShowNoResultsFound, searchValue); return ( - - {typeFiltersKeys.map((section, index) => ( - - {index !== 0 && ( - - )} - {section.map((item) => ( - - ))} + + + {shouldShowNoResultsFound && ( + + + {noResultsFoundText} + - ))} - + )} + + {headerContent} + {filters.map((section, index) => ( + + {index !== 0 && ( + + )} + {section.map((item) => ( + + ))} + + ))} + + ); } diff --git a/src/components/Search/FilterComponents/AdvancedFilters/SearchNLFilterContent.tsx b/src/components/Search/FilterComponents/AdvancedFilters/SearchNLFilterContent.tsx index eee16faef67e..263de1598b6d 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/SearchNLFilterContent.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/SearchNLFilterContent.tsx @@ -42,9 +42,12 @@ type SearchNLFilterContentProps = { /** Size of the submit button */ size?: Exclude, typeof CONST.BUTTON_SIZE.SMALL>; + + /** Whether the input takes the focus once it is shown */ + autoFocus?: boolean; }; -function SearchNLFilterContent({onSuccess, containerStyle, buttonContainerStyle, size = CONST.BUTTON_SIZE.MEDIUM}: SearchNLFilterContentProps) { +function SearchNLFilterContent({onSuccess, containerStyle, buttonContainerStyle, size = CONST.BUTTON_SIZE.MEDIUM, autoFocus}: SearchNLFilterContentProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const [nlQuery, setNlQuery] = useState(''); @@ -129,7 +132,7 @@ function SearchNLFilterContent({onSuccess, containerStyle, buttonContainerStyle, e.preventDefault(); handleSubmit(); }} - autoFocus + autoFocus={autoFocus} autoGrowHeight maxAutoGrowHeight={variables.textInputAutoGrowMaxHeight} /> diff --git a/src/components/Search/FilterDropdowns/FilterPopupButton.tsx b/src/components/Search/FilterDropdowns/FilterPopupButton.tsx index 351f81a43ee1..c34e778a5de9 100644 --- a/src/components/Search/FilterDropdowns/FilterPopupButton.tsx +++ b/src/components/Search/FilterDropdowns/FilterPopupButton.tsx @@ -45,6 +45,9 @@ type FilterPopupButtonProps = { /** Called instead of opening the popover when device is in landscape mode */ onLandscapePress?: () => void; + + /** Called once the popover has finished opening */ + onModalShow?: () => void; }; const ANCHOR_ORIGIN = { @@ -60,6 +63,7 @@ function FilterPopupButton({ PopoverComponent, renderButton, onLandscapePress, + onModalShow, }: FilterPopupButtonProps) { // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to distinguish RHP and narrow layout // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -141,6 +145,7 @@ function FilterPopupButton({ avoidKeyboard isVisible={isOverlayVisible} onClose={toggleOverlay} + onModalShow={onModalShow} anchorPosition={popoverTriggerPosition} anchorAlignment={popoverAnchorAlignment} restoreFocusType={CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE} diff --git a/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx b/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx index bea9ad4a791d..45373e45064a 100644 --- a/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx +++ b/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx @@ -1,3 +1,4 @@ +import BlockingView from '@components/BlockingViews/BlockingView'; import Icon from '@components/Icon'; import {PressableWithFeedback} from '@components/Pressable'; import SafeTriangle from '@components/SafeTriangle'; @@ -8,9 +9,12 @@ import useUpdateFilterQuery from '@components/Search/hooks/useUpdateFilterQuery' import type {SearchQueryJSON} from '@components/Search/types'; import SpacerView from '@components/SpacerView'; import Text from '@components/Text'; +import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; +import useAdvancedSearchFilters from '@hooks/useAdvancedSearchFilters'; import {useDebounceWithControls} from '@hooks/useDebounce'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useDebouncedState from '@hooks/useDebouncedState'; +import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; @@ -22,7 +26,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import getButtonState from '@libs/getButtonState'; import Navigation from '@libs/Navigation/Navigation'; -import {hasFilterContentValuesChanged} from '@libs/SearchUIUtils'; +import {getMatchingFilters, hasFilterContentValuesChanged, isLabelMatchingSearchValue} from '@libs/SearchUIUtils'; import type {SearchFilter} from '@libs/SearchUIUtils'; import variables from '@styles/variables'; @@ -32,7 +36,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; -import type {ComponentRef} from 'react'; +import type {ComponentRef, Ref} from 'react'; import React, {Activity, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -49,6 +53,9 @@ type SearchAdvancedFiltersPopupProps = { /** Closes the filters popover overlay */ closeOverlay: () => void; + + /** Ref to the filter search input, so the popover can focus it once it has opened */ + filterSearchInputRef?: Ref; }; /** Which filter contents are mounted and what each was given. */ @@ -125,7 +132,7 @@ function MountedFilterContent({filterKey, values, ready, onChange}: MountedFilte ); } -function SearchAdvancedFiltersPopup({queryJSON, closeOverlay}: SearchAdvancedFiltersPopupProps) { +function SearchAdvancedFiltersPopup({queryJSON, closeOverlay, filterSearchInputRef}: SearchAdvancedFiltersPopupProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const theme = useTheme(); @@ -133,10 +140,11 @@ function SearchAdvancedFiltersPopup({queryJSON, closeOverlay}: SearchAdvancedFil const {windowHeight} = useWindowDimensions(); const {isBetaEnabled} = usePermissions(); const canUseNLFilters = isBetaEnabled(CONST.BETAS.NL_FILTERS); - const [isDescribeMode, setIsDescribeMode] = useState(false); + const [isDescribeMode, setIsDescribeMode] = useState(canUseNLFilters); const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); const filterContentRef = useRef>(null); const icons = useMemoizedLazyExpensifyIcons(['Sparkles', 'ArrowRight']); + const illustrations = useMemoizedLazyIllustrations(['Filters']); const [mountedFilterState, setMountedFilterState] = useState(() => ({ activeFilter: INITIAL_FILTER, mountedFilters: [INITIAL_FILTER], @@ -147,6 +155,7 @@ function SearchAdvancedFiltersPopup({queryJSON, closeOverlay}: SearchAdvancedFil })); const {activeFilter, mountedFilters, formWhileHidden, contentVersions, readyFilters} = mountedFilterState; const {updateFilterQueryParams} = useUpdateFilterQuery(queryJSON); + const allFilters = useAdvancedSearchFilters(searchAdvancedFiltersForm?.type); // Shows a filter's content. A visited one stays mounted, so a return reveals it rather than building it again. const showFilter = (filterKey: SearchFilter['key']) => { @@ -217,6 +226,52 @@ function SearchAdvancedFiltersPopup({queryJSON, closeOverlay}: SearchAdvancedFil markShownFilterReady(); }; + const isDescribeSearchMatching = (searchValue: string) => canUseNLFilters && isLabelMatchingSearchValue(translate('search.filters.describeSearch.title'), searchValue); + + // Like a selection list, a search shows its first match. The typing has stopped, so its content loads at once. + const showFirstMatchingFilter = (searchValue: string) => { + if (isDescribeSearchMatching(searchValue)) { + setIsDescribeMode(true); + return; + } + + const firstMatchingFilter = getMatchingFilters(allFilters, searchAdvancedFiltersForm?.type, searchValue, translate).at(0)?.at(0); + if (!firstMatchingFilter) { + return; + } + + setIsDescribeMode(false); + cancelReadyWait(); + showFilter(firstMatchingFilter); + markShownFilterReady(); + }; + + const [filterSearchValue, debouncedFilterSearchValue, setFilterSearchValue] = useDebouncedState('', CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME, { + onDebouncedValueChange: (searchValue) => { + // A cleared search showed the first filter as it was cleared, and the cursor may have picked another since. + if (!searchValue.trim()) { + return; + } + + showFirstMatchingFilter(searchValue); + }, + }); + // A cleared input lists every filter at once rather than after the debounce, as in useSearchResults. + const listedSearchValue = filterSearchValue.trim() ? debouncedFilterSearchValue : ''; + const filters = getMatchingFilters(allFilters, searchAdvancedFiltersForm?.type, listedSearchValue, translate); + const isActiveFilterListed = filters.some((section) => section.includes(activeFilter)); + const isDescribeSearchListed = isDescribeSearchMatching(listedSearchValue); + + // A cleared input lists every filter without waiting, so the content goes back to the first one with it. + const changeFilterSearchValue = (searchValue: string) => { + setFilterSearchValue(searchValue); + if (searchValue.trim()) { + return; + } + + showFirstMatchingFilter(searchValue); + }; + const mayDeriveContent = (filterKey: SearchFilter['key']) => !DEFERRED_CONTENT_FILTERS.has(filterKey) || readyFilters.includes(filterKey); const getDescribeButtonBackground = (pressed: boolean) => { @@ -238,46 +293,52 @@ function SearchAdvancedFiltersPopup({queryJSON, closeOverlay}: SearchAdvancedFil - {canUseNLFilters && ( - <> - [styles.typeFilterMenu, getDescribeButtonBackground(pressed)]} - accessible - accessibilityLabel={translate('search.filters.describeSearch.title')} - role={CONST.ROLE.BUTTON} - sentryLabel="SearchAdvancedFiltersPopup-DescribeSearch" - onHoverIn={() => setIsDescribeMode(true)} - onFocus={() => setIsDescribeMode(true)} - onPress={() => setIsDescribeMode(true)} - > - {({pressed}) => ( - <> - - {translate('search.filters.describeSearch.title')} - - - )} - - - - )} + [styles.typeFilterMenu, getDescribeButtonBackground(pressed)]} + accessible + accessibilityLabel={translate('search.filters.describeSearch.title')} + role={CONST.ROLE.BUTTON} + sentryLabel="SearchAdvancedFiltersPopup-DescribeSearch" + onHoverIn={() => setIsDescribeMode(true)} + onFocus={() => setIsDescribeMode(true)} + onPress={() => setIsDescribeMode(true)} + > + {({pressed}) => ( + <> + + {translate('search.filters.describeSearch.title')} + + + )} + + + + ) + } onHoverIn={hoverFilter} onPointerMove={trackPointerMovement} onPointerLeave={stopTrackingPointer} @@ -288,25 +349,29 @@ function SearchAdvancedFiltersPopup({queryJSON, closeOverlay}: SearchAdvancedFil ref={filterContentRef} style={[styles.filterContentContainer]} > - {isDescribeMode ? ( - - ) : ( - mountedFilters.map((filterKey) => ( - // `Activity` keeps a hidden content mounted while taking it out of layout and unmounting its - // effects, so it holds no Onyx subscriptions until it is shown again. - - - - )) + {(isDescribeMode ? !isDescribeSearchListed : !isActiveFilterListed) && ( + )} + {isDescribeMode + ? isDescribeSearchListed && + : mountedFilters.map((filterKey) => ( + // `Activity` keeps a hidden content mounted while taking it out of layout and unmounting its + // effects, so it holds no Onyx subscriptions until it is shown again. + + + + ))} diff --git a/src/components/Search/SearchPageHeader/SearchAdvancedFiltersButton.tsx b/src/components/Search/SearchPageHeader/SearchAdvancedFiltersButton.tsx index d6ab3390e4b9..caa2e1992aa1 100644 --- a/src/components/Search/SearchPageHeader/SearchAdvancedFiltersButton.tsx +++ b/src/components/Search/SearchPageHeader/SearchAdvancedFiltersButton.tsx @@ -5,6 +5,7 @@ import FilterPopupButton from '@components/Search/FilterDropdowns/FilterPopupBut import type {ButtonComponentProps, PopoverComponentProps} from '@components/Search/FilterDropdowns/FilterPopupButton'; import SearchAdvancedFiltersPopup from '@components/Search/FilterDropdowns/SearchAdvancedFiltersPopup'; import type {SearchQueryJSON} from '@components/Search/types'; +import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import useFilterFormValues from '@hooks/useFilterFormValues'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -16,13 +17,14 @@ import useSearchFilterSync, {shouldDeferSearchFilterSync} from '@hooks/useSearch import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import React from 'react'; +import React, {useRef} from 'react'; type SearchAdvancedFiltersButtonProp = { queryJSON: SearchQueryJSON; @@ -37,6 +39,7 @@ function SearchAdvancedFiltersButton({queryJSON}: SearchAdvancedFiltersButtonPro const expensifyIcons = useMemoizedLazyExpensifyIcons(['Filter']); const {isOffline} = useNetwork(); const filterFormValues = useFilterFormValues(queryJSON); + const filterSearchInputRef = useRef(null); const [areCategoriesLoaded] = useOnyx(ONYXKEYS.IS_SEARCH_FILTERS_CATEGORY_DATA_LOADED); const [isLoadingCategories] = useOnyx(ONYXKEYS.RAM_ONLY_IS_LOADING_SEARCH_FILTERS_CATEGORY_DATA); const shouldDeferFilterSync = shouldDeferSearchFilterSync(queryJSON, areCategoriesLoaded, isLoadingCategories, isOffline); @@ -97,12 +100,20 @@ function SearchAdvancedFiltersButton({queryJSON}: SearchAdvancedFiltersButtonPro ); return ( { + if (!canFocusInputOnScreenFocus()) { + return; + } + filterSearchInputRef.current?.focus(); + }} popoverWidth={CONST.ADVANCED_FILTERS_POPOVER_WIDTH} popoverAnchorAlignment={{ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, diff --git a/src/hooks/useAdvancedSearchFilters.ts b/src/hooks/useAdvancedSearchFilters.ts index cb76d36a79b9..c2f63fa2915e 100644 --- a/src/hooks/useAdvancedSearchFilters.ts +++ b/src/hooks/useAdvancedSearchFilters.ts @@ -29,53 +29,56 @@ const typeFiltersKeys = { CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID, + CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD, ], [ - CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE, - CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY, + CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION, CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY, CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG, CONST.SEARCH.SYNTAX_FILTER_KEYS.VENDOR, - CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE, - CONST.SEARCH.SYNTAX_FILTER_KEYS.ATTENDEE, CONST.SEARCH.SYNTAX_FILTER_KEYS.REIMBURSABLE, CONST.SEARCH.SYNTAX_FILTER_KEYS.BILLABLE, - CONST.SEARCH.SYNTAX_FILTER_KEYS.DECLINED, - CONST.SEARCH.SYNTAX_FILTER_KEYS.HELD, + CONST.SEARCH.SYNTAX_FILTER_KEYS.ATTENDEE, + CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE, + CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE, + CONST.SEARCH.SYNTAX_FILTER_KEYS.RECEIPT_TYPE, + CONST.SEARCH.SYNTAX_FILTER_KEYS.HAS, + CONST.SEARCH.SYNTAX_FILTER_KEYS.PURCHASE_AMOUNT, + CONST.SEARCH.SYNTAX_FILTER_KEYS.PURCHASE_CURRENCY, + ], + [ CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, CONST.SEARCH.SYNTAX_FILTER_KEYS.BANK_ACCOUNT, CONST.SEARCH.SYNTAX_FILTER_KEYS.FEED, CONST.SEARCH.SYNTAX_FILTER_KEYS.TRANSACTION_STATUS, - CONST.SEARCH.SYNTAX_FILTER_KEYS.PURCHASE_AMOUNT, - CONST.SEARCH.SYNTAX_FILTER_KEYS.PURCHASE_CURRENCY, - CONST.SEARCH.SYNTAX_FILTER_KEYS.RECEIPT_TYPE, + CONST.SEARCH.SYNTAX_FILTER_KEYS.POSTED, + ], + [ CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_ID, CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_FIELD, CONST.SEARCH.SYNTAX_FILTER_KEYS.SUBMITTER_USER_ID, CONST.SEARCH.SYNTAX_FILTER_KEYS.SUBMITTER_PAYROLL_ID, - CONST.SEARCH.SYNTAX_FILTER_KEYS.ORDER_DEAL_NUMBERS, - CONST.SEARCH.SYNTAX_FILTER_KEYS.HAS, - CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD, ], [ CONST.SEARCH.SYNTAX_FILTER_KEYS.SUBMITTED, + CONST.SEARCH.SYNTAX_FILTER_KEYS.DECLINED, + CONST.SEARCH.SYNTAX_FILTER_KEYS.HELD, CONST.SEARCH.SYNTAX_FILTER_KEYS.APPROVED, CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL, - CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED, - CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID, CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_BY, - CONST.SEARCH.SYNTAX_FILTER_KEYS.POSTED, ], + [CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED, CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED_TO], [ CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_ID, CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_TYPE, CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_STATUS, CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWN, + CONST.SEARCH.SYNTAX_FILTER_KEYS.ORDER_DEAL_NUMBERS, ], ], [CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT]: [ @@ -85,31 +88,37 @@ const typeFiltersKeys = { CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID, + CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD, ], [ - CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, CONST.SEARCH.SYNTAX_FILTER_KEYS.TOTAL, - CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT_DEBITED, - CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT_REIMBURSED, CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY, - CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED_TO, - CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_STATUS, - CONST.SEARCH.SYNTAX_FILTER_KEYS.DECLINED, - CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_BY, + CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, CONST.SEARCH.SYNTAX_FILTER_KEYS.TITLE, + CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT_DEBITED, + CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT_REIMBURSED, + ], + [ CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_ID, CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_FIELD, CONST.SEARCH.SYNTAX_FILTER_KEYS.SUBMITTER_USER_ID, CONST.SEARCH.SYNTAX_FILTER_KEYS.SUBMITTER_PAYROLL_ID, - CONST.SEARCH.SYNTAX_FILTER_KEYS.ORDER_DEAL_NUMBERS, - CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD, ], - [CONST.SEARCH.SYNTAX_FILTER_KEYS.SUBMITTED, CONST.SEARCH.SYNTAX_FILTER_KEYS.APPROVED, CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED, CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID], + [ + CONST.SEARCH.SYNTAX_FILTER_KEYS.SUBMITTED, + CONST.SEARCH.SYNTAX_FILTER_KEYS.DECLINED, + CONST.SEARCH.SYNTAX_FILTER_KEYS.APPROVED, + CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID, + CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_BY, + CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_STATUS, + ], + [CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED, CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED_TO], [ CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_ID, CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_TYPE, CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_STATUS, CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWN, + CONST.SEARCH.SYNTAX_FILTER_KEYS.ORDER_DEAL_NUMBERS, ], ], [CONST.SEARCH.DATA_TYPES.INVOICE]: [ @@ -119,33 +128,26 @@ const typeFiltersKeys = { CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID, + CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD, ], [ - CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, + CONST.SEARCH.SYNTAX_FILTER_KEYS.TOTAL, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY, + CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, + CONST.SEARCH.SYNTAX_FILTER_KEYS.TITLE, CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION, CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY, CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG, CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE, - CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, - CONST.SEARCH.SYNTAX_FILTER_KEYS.FEED, CONST.SEARCH.SYNTAX_FILTER_KEYS.PURCHASE_AMOUNT, CONST.SEARCH.SYNTAX_FILTER_KEYS.PURCHASE_CURRENCY, - CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD, - ], - [ - CONST.SEARCH.SYNTAX_FILTER_KEYS.SUBMITTED, - CONST.SEARCH.SYNTAX_FILTER_KEYS.APPROVED, - CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED, - CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID, - CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_BY, - CONST.SEARCH.SYNTAX_FILTER_KEYS.POSTED, - CONST.SEARCH.SYNTAX_FILTER_KEYS.TOTAL, - CONST.SEARCH.SYNTAX_FILTER_KEYS.TITLE, - CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_ID, ], + [CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, CONST.SEARCH.SYNTAX_FILTER_KEYS.FEED, CONST.SEARCH.SYNTAX_FILTER_KEYS.POSTED], + [CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_ID], + [CONST.SEARCH.SYNTAX_FILTER_KEYS.SUBMITTED, CONST.SEARCH.SYNTAX_FILTER_KEYS.APPROVED, CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID, CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_BY], + [CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED], [ CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_ID, CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_TYPE, @@ -160,34 +162,26 @@ const typeFiltersKeys = { CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID, + CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD, ], [ - CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, + CONST.SEARCH.SYNTAX_FILTER_KEYS.TOTAL, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY, + CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, + CONST.SEARCH.SYNTAX_FILTER_KEYS.TITLE, CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION, CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY, CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG, CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE, - CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, - CONST.SEARCH.SYNTAX_FILTER_KEYS.FEED, CONST.SEARCH.SYNTAX_FILTER_KEYS.PURCHASE_AMOUNT, CONST.SEARCH.SYNTAX_FILTER_KEYS.PURCHASE_CURRENCY, - CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD, - ], - [ - CONST.SEARCH.SYNTAX_FILTER_KEYS.SUBMITTED, - CONST.SEARCH.SYNTAX_FILTER_KEYS.APPROVED, - CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED, - CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED_TO, - CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID, - CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_BY, - CONST.SEARCH.SYNTAX_FILTER_KEYS.POSTED, - CONST.SEARCH.SYNTAX_FILTER_KEYS.TOTAL, - CONST.SEARCH.SYNTAX_FILTER_KEYS.TITLE, - CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_ID, ], + [CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, CONST.SEARCH.SYNTAX_FILTER_KEYS.FEED, CONST.SEARCH.SYNTAX_FILTER_KEYS.POSTED], + [CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_ID], + [CONST.SEARCH.SYNTAX_FILTER_KEYS.SUBMITTED, CONST.SEARCH.SYNTAX_FILTER_KEYS.APPROVED, CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID, CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_BY], + [CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED, CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTED_TO], [CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_ID, CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_TYPE, CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWN], ], [CONST.SEARCH.DATA_TYPES.CHAT]: [ diff --git a/src/languages/de.ts b/src/languages/de.ts index caa271024345..4547b79f97ba 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -10510,6 +10510,8 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc [CONST.SEARCH.TRANSACTION_STATUS.POSTED]: 'Gebucht', hint: 'Gilt nur für Kartentransaktionen.', }, + searchFilters: 'Suchfilter', + noFiltersSelected: 'Keine Filter ausgewählt', }, display: { label: 'Anzeige', diff --git a/src/languages/el.ts b/src/languages/el.ts index 26c91aac8505..9a49d204cfc6 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -10719,6 +10719,8 @@ ${reportName}`, [CONST.SEARCH.TRANSACTION_STATUS.POSTED]: 'Καταχωρίστηκε', hint: 'Ισχύει μόνο για συναλλαγές με κάρτα.', }, + searchFilters: 'Φίλτρα αναζήτησης', + noFiltersSelected: 'Δεν έχουν επιλεγεί φίλτρα', }, display: { label: 'Εμφάνιση', diff --git a/src/languages/en.ts b/src/languages/en.ts index 0e618dbf01c8..34a5b19d690c 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -10721,6 +10721,8 @@ const translations = { billable: 'Billable', rejected: 'Rejected', held: 'Held', + searchFilters: 'Search filters', + noFiltersSelected: 'No filters selected', reimbursable: 'Reimbursable', purchaseCurrency: 'Purchase currency', sortOrder: { diff --git a/src/languages/es.ts b/src/languages/es.ts index a8afd6b3e352..a9b05f63a2c8 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -10356,6 +10356,8 @@ ${reportName}`, [CONST.SEARCH.TRANSACTION_STATUS.POSTED]: 'Publicado', hint: 'Solo se aplica a las transacciones con tarjeta.', }, + searchFilters: 'Filtros de búsqueda', + noFiltersSelected: 'No se han seleccionado filtros', }, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: 'De', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 0eb4fad8cc04..a53dbf3320c6 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -10536,6 +10536,8 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e [CONST.SEARCH.TRANSACTION_STATUS.POSTED]: 'Publié', hint: 'S’applique uniquement aux transactions par carte.', }, + searchFilters: 'Filtres de recherche', + noFiltersSelected: 'Aucun filtre sélectionné', }, display: { label: 'Affichage', diff --git a/src/languages/it.ts b/src/languages/it.ts index 47dac5ee254c..f2098ba95dea 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -10464,6 +10464,8 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, [CONST.SEARCH.TRANSACTION_STATUS.POSTED]: 'Registrato', hint: 'Si applica solo alle transazioni con carta.', }, + searchFilters: 'Filtri di ricerca', + noFiltersSelected: 'Nessun filtro selezionato', }, display: { label: 'Visualizza', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index c3a95ad0061c..58e2c575ab24 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -10312,6 +10312,8 @@ ${reportName}`, [CONST.SEARCH.TRANSACTION_STATUS.POSTED]: '記帳済み', hint: 'カードの取引にのみ適用されます。', }, + searchFilters: '検索フィルター', + noFiltersSelected: 'フィルターが選択されていません', }, display: { label: '表示', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 179cddfa9074..1f6337790ed0 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -10433,6 +10433,8 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, [CONST.SEARCH.TRANSACTION_STATUS.POSTED]: 'Geboekt', hint: 'Alleen van toepassing op kaarttransacties.', }, + searchFilters: 'Zoekfilters', + noFiltersSelected: 'Geen filters geselecteerd', }, display: { label: 'Weergave', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 7807272c113f..7adf3b149be7 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -10455,6 +10455,8 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, [CONST.SEARCH.TRANSACTION_STATUS.POSTED]: 'Zaksięgowane', hint: 'Dotyczy tylko transakcji kartą.', }, + searchFilters: 'Filtry wyszukiwania', + noFiltersSelected: 'Nie wybrano filtrów', }, display: { label: 'Wyświetl', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index d7d3eeaa0f6c..ff0799241d7c 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -10426,6 +10426,8 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, [CONST.SEARCH.TRANSACTION_STATUS.POSTED]: 'Postado', hint: 'Aplica-se apenas a transações com cartão.', }, + searchFilters: 'Filtros de busca', + noFiltersSelected: 'Nenhum filtro selecionado', }, display: { label: 'Exibir', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 021fb04e0cd8..9ba19439c684 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -10048,6 +10048,8 @@ ${reportName}`, [CONST.SEARCH.TRANSACTION_STATUS.POSTED]: '已入账', hint: '仅适用于银行卡交易。', }, + searchFilters: '搜索筛选条件', + noFiltersSelected: '未选择任何筛选器', }, display: { label: '显示', diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index c975ede6c409..b06042652d2f 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -221,6 +221,7 @@ import { } from './SearchQueryUtils'; import {expenseStatusActionMapping, getSuggestedSearches, isEligibleForStatus, SEARCH_TYPE_MENU_ICON_NAMES} from './SearchSuggestionUtils'; import StringUtils from './StringUtils'; +import tokenizedSearch from './tokenizedSearch'; import { getAmount, getAttendees, @@ -6388,12 +6389,30 @@ function isMappedFilterKey(key: string): key is MappedFilterKey { } function getFilterViewLabelKey(filterKey: keyof typeof FILTER_VIEW_MAP, type?: SearchDataTypes): TranslationPaths { - if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE && isCreatedDateType(type)) { + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE && (isCreatedDateType(type) || type === CONST.SEARCH.DATA_TYPES.INVOICE)) { return 'search.filters.createdDate'; } return FILTER_VIEW_MAP[filterKey].labelKey; } +function isLabelMatchingSearchValue(label: string, searchValue: string): boolean { + return tokenizedSearch([label], searchValue, (item) => [item]).length > 0; +} + +function isFilterMatchingSearchValue(filterKey: keyof typeof FILTER_VIEW_MAP, type: SearchDataTypes | undefined, searchValue: string, translate: LocalizedTranslate): boolean { + return isLabelMatchingSearchValue(translate(getFilterViewLabelKey(filterKey, type)), searchValue); +} + +/** Keeps the filters whose label contains every word of the search value, dropping the sections left empty. */ +function getMatchingFilters( + filters: Array>, + type: SearchDataTypes | undefined, + searchValue: string, + translate: LocalizedTranslate, +): Array> { + return filters.map((section) => section.filter((filterKey) => isFilterMatchingSearchValue(filterKey, type, searchValue, translate))).filter((section) => section.length > 0); +} + function mapFiltersFormToLabelValueList( searchAdvancedFiltersForm: Partial, defaultSearchQueryFilterKeys: Set, @@ -7719,6 +7738,9 @@ export { RECONCILIATION_SEARCH_KEYS, FILTER_VIEW_MAP, getFilterViewLabelKey, + isLabelMatchingSearchValue, + isFilterMatchingSearchValue, + getMatchingFilters, isCreatedDateType, doesSearchItemMatchSort, isPolicyEligibleForSpendOverTime, diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx index 277f72eda168..45f62e682641 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchAdvancedFiltersBase.tsx @@ -7,6 +7,8 @@ import FilterList from '@components/Search/FilterComponents/AdvancedFilters/Filt import SpacerView from '@components/SpacerView'; import Text from '@components/Text'; +import useAdvancedSearchFilters from '@hooks/useAdvancedSearchFilters'; +import useDebouncedState from '@hooks/useDebouncedState'; import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -17,6 +19,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import getButtonState from '@libs/getButtonState'; import Navigation from '@libs/Navigation/Navigation'; +import {getMatchingFilters, isLabelMatchingSearchValue} from '@libs/SearchUIUtils'; import {SearchAdvancedFiltersActionContext, SearchAdvancedFiltersContext} from '@pages/Search/SearchAdvancedFiltersProvider'; @@ -39,6 +42,12 @@ function SearchAdvancedFiltersBase() { const {isBetaEnabled} = usePermissions(); const canUseNLFilters = isBetaEnabled(CONST.BETAS.NL_FILTERS); const icons = useMemoizedLazyExpensifyIcons(['Sparkles', 'ArrowRight']); + const [filterSearchValue, debouncedFilterSearchValue, setFilterSearchValue] = useDebouncedState('', CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + const allFilters = useAdvancedSearchFilters(currentDraftFilters.type); + // A cleared input lists every filter at once rather than after the debounce, as in useSearchResults. + const listedSearchValue = filterSearchValue.trim() ? debouncedFilterSearchValue : ''; + const filters = getMatchingFilters(allFilters, currentDraftFilters.type, listedSearchValue, translate); + const isDescribeSearchListed = canUseNLFilters && isLabelMatchingSearchValue(translate('search.filters.describeSearch.title'), listedSearchValue); return ( - {canUseNLFilters && ( - <> - [styles.typeFilterMenu, pressed && styles.buttonHoveredBG]} - accessible - accessibilityLabel={translate('search.filters.describeSearch.title')} - sentryLabel="SearchAdvancedFiltersBase-DescribeSearch" - onPress={() => Navigation.navigate(ROUTES.SEARCH_NL_FILTER)} - > - {({pressed}) => ( - <> - - {translate('search.filters.describeSearch.title')} - - - )} - - - - )} + [styles.typeFilterMenu, pressed && styles.buttonHoveredBG]} + accessible + accessibilityLabel={translate('search.filters.describeSearch.title')} + sentryLabel="SearchAdvancedFiltersBase-DescribeSearch" + onPress={() => Navigation.navigate(ROUTES.SEARCH_NL_FILTER)} + > + {({pressed}) => ( + <> + + {translate('search.filters.describeSearch.title')} + + + )} + + + + ) + } onPress={(filterKey) => Navigation.navigate(ROUTES.SEARCH_ADVANCED_FILTERS_CONTENT.getRoute(filterKey))} /> diff --git a/src/pages/Search/SearchNLFilterPage.tsx b/src/pages/Search/SearchNLFilterPage.tsx index 58e352207617..3aac2168558c 100644 --- a/src/pages/Search/SearchNLFilterPage.tsx +++ b/src/pages/Search/SearchNLFilterPage.tsx @@ -49,6 +49,7 @@ function SearchNLFilterPage() { ); diff --git a/tests/unit/Search/CreatedDateScopedLabelsTest.ts b/tests/unit/Search/CreatedDateScopedLabelsTest.ts index 6baa0013f60e..01741e195485 100644 --- a/tests/unit/Search/CreatedDateScopedLabelsTest.ts +++ b/tests/unit/Search/CreatedDateScopedLabelsTest.ts @@ -46,13 +46,13 @@ describe('Created date scoped labels (#98148)', () => { }); describe('getFilterViewLabelKey (filter menu row, applied chip, filter subpage title)', () => { - it('returns "Created date" for the DATE filter in expense-report and task search', () => { + it('returns "Created date" for the DATE filter in expense-report, task and invoice search', () => { expect(getFilterViewLabelKey(CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, EXPENSE_REPORT)).toBe('search.filters.createdDate'); expect(getFilterViewLabelKey(CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, TASK)).toBe('search.filters.createdDate'); + expect(getFilterViewLabelKey(CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, INVOICE)).toBe('search.filters.createdDate'); }); - it('keeps "Date" for the DATE filter in invoice, expense, trip and with no type', () => { - expect(getFilterViewLabelKey(CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, INVOICE)).toBe('common.date'); + it('keeps "Date" for the DATE filter in expense, trip and with no type', () => { expect(getFilterViewLabelKey(CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, EXPENSE)).toBe('common.date'); expect(getFilterViewLabelKey(CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, TRIP)).toBe('common.date'); expect(getFilterViewLabelKey(CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE)).toBe('common.date'); diff --git a/tests/unit/Search/SearchAdvancedFiltersPopupTest.tsx b/tests/unit/Search/SearchAdvancedFiltersPopupTest.tsx index 8412fb3d8327..5a32cc0559bd 100644 --- a/tests/unit/Search/SearchAdvancedFiltersPopupTest.tsx +++ b/tests/unit/Search/SearchAdvancedFiltersPopupTest.tsx @@ -1,13 +1,18 @@ -import {act, render} from '@testing-library/react-native'; +import {act, fireEvent, render, screen} from '@testing-library/react-native'; import type {SearchQueryJSON} from '@components/Search/types'; import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; +import type {ReactNode} from 'react'; + import React, {useRef} from 'react'; import {View} from 'react-native'; +import type * as TestHelper from '../../utils/TestHelper'; + // jest.mock factories can't reference imported bindings, but `mock`-prefixed locals are allowed. const MockView = View; const mockUseRef = useRef; @@ -32,11 +37,15 @@ let mockOnFocus: ((filterKey: string) => void) | undefined; let mockOnPointerMove: ((event: {clientX: number; clientY: number}) => void) | undefined; let mockOnPointerLeave: ((event: {clientX: number}) => void) | undefined; let mockSelectedFilter: string | undefined; +let mockOnSearchValueChange: ((searchValue: string) => void) | undefined; + +/** Whether the popup last told the Describe your search content to take the focus. */ +let mockIsDescribeSearchAutoFocused = false; /** The form served by the useOnyx mock. Reassigned by tests to change filter values. */ let mockFiltersForm: Partial | undefined; -// Stand-in for the filter list: it renders no rows and only hands the tests what the popup gave it. +// Stand-in for the filter list: it renders no rows, only the content the popup puts above them, and hands the tests what the popup gave it. jest.mock('@components/Search/FilterComponents/AdvancedFilters/FilterList', () => ({ __esModule: true, default: (props: { @@ -45,17 +54,34 @@ jest.mock('@components/Search/FilterComponents/AdvancedFilters/FilterList', () = onPointerMove: (event: {clientX: number; clientY: number}) => void; onPointerLeave: (event: {clientX: number}) => void; selectedFilter: string; + onSearchValueChange: (searchValue: string) => void; + headerContent: ReactNode; }) => { mockOnHoverIn = props.onHoverIn; mockOnFocus = props.onFocus; mockOnPointerMove = props.onPointerMove; mockOnPointerLeave = props.onPointerLeave; mockSelectedFilter = props.selectedFilter; + mockOnSearchValueChange = props.onSearchValueChange; + + return props.headerContent; + }, +})); + +jest.mock('@components/Search/FilterComponents/AdvancedFilters/SearchNLFilterContent', () => ({ + __esModule: true, + default: ({autoFocus}: {autoFocus?: boolean}) => { + mockIsDescribeSearchAutoFocused = !!autoFocus; - return null; + return ; }, })); +jest.mock('@hooks/usePermissions', () => ({ + __esModule: true, + default: () => ({isBetaEnabled: () => true}), +})); + // Stand-in for the filter content, the expensive part being mounted or kept alive. jest.mock('@components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent', () => ({ __esModule: true, @@ -80,6 +106,12 @@ jest.mock('@components/Search/hooks/useUpdateFilterQuery', () => ({ default: () => ({updateFilterQueryParams: mockUpdateFilterQueryParams}), })); +// The default locale context translates every label to an empty string, and the filter search matches labels. +jest.mock('@hooks/useLocalize', () => { + const {translateLocal, localeCompare} = jest.requireActual('../../utils/TestHelper'); + return () => ({translate: translateLocal, localeCompare}); +}); + jest.mock('@hooks/useOnyx', () => ({ __esModule: true, default: () => [mockFiltersForm, {status: 'loaded'}], @@ -125,6 +157,21 @@ function leaveList(clientX = Number.MAX_SAFE_INTEGER) { }); } +/** Types into the filter search input and pauses long enough for the list to follow. */ +function search(searchValue: string) { + act(() => { + mockOnSearchValueChange?.(searchValue); + }); + act(() => { + jest.advanceTimersByTime(CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + }); +} + +/** Picks the Describe your search row. */ +function pressDescribeSearch() { + fireEvent.press(screen.getByLabelText('Describe your search')); +} + /** Moves the keyboard focus onto a filter row. */ function focus(filterKey: string) { act(() => { @@ -132,6 +179,10 @@ function focus(filterKey: string) { }); } +beforeAll(async () => { + await IntlStore.load('en'); +}); + beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); @@ -274,4 +325,139 @@ describe('SearchAdvancedFiltersPopup', () => { // No timer is advanced: moving focus is deliberate and never passes over rows on the way. expect(mockReadyByFilter[FILTER_KEYS.FROM]).toBe(true); }); + + it('shows the first match once the typing pauses', () => { + // Given the popover open on Describe your search + // When a search that Describe your search does not match is typed, before the typing pauses + act(() => { + mockOnSearchValueChange?.('status'); + }); + + // Then the content stays put while the typing may go on + expect(screen.getByTestId('describe-search-content')).toBeOnTheScreen(); + + // When the typing pauses + act(() => { + jest.advanceTimersByTime(CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + }); + + // Then Status, the first match, takes over the content + expect(mockSelectedFilter).toBe(FILTER_KEYS.STATUS); + }); + + it('shows the first match even when the shown filter still matches', () => { + // Given Withdrawal status shown, which sits below Status in the list + hoverAndRest(FILTER_KEYS.WITHDRAWAL_STATUS); + + // When a search that both filters match is typed + search('status'); + + // Then Status takes over, as a selection list moves to its first match on every search + expect(mockSelectedFilter).toBe(FILTER_KEYS.STATUS); + }); + + it('goes back to the first filter as soon as the search is cleared', () => { + // Given a search, then another of its matches picked + search('status'); + hoverAndRest(FILTER_KEYS.WITHDRAWAL_STATUS); + + // When the search is cleared, before any debounce elapses + act(() => { + mockOnSearchValueChange?.(''); + }); + + // Then Describe your search, the first row of the full list, takes over at once, the same moment the full list comes back + expect(screen.getByTestId('describe-search-content')).toBeOnTheScreen(); + }); + + it('keeps a filter hovered right after the search is cleared', () => { + // Given a cleared search, then a filter hovered before the debounce elapses + search('status'); + act(() => { + mockOnSearchValueChange?.(''); + }); + hoverAndRest(FILTER_KEYS.AMOUNT); + + // When the debounce of the cleared value elapses + act(() => { + jest.advanceTimersByTime(CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + }); + + // Then the hovered filter stays, because the clear already showed the first filter + expect(mockSelectedFilter).toBe(FILTER_KEYS.AMOUNT); + }); + + it('lets a contact list filter the search lands on derive at once', () => { + // Given the popover open on Type + // When a search whose first match is From is typed and the typing pauses + search('from'); + + // Then From derives its contact list straight away, because the typing has already settled + expect(mockSelectedFilter).toBe(FILTER_KEYS.FROM); + expect(mockReadyByFilter[FILTER_KEYS.FROM]).toBe(true); + }); + + it('shows the empty state when the search lists no filter', () => { + // Given the popover open on Type + // When a search that no filter matches is typed + search('unicorn'); + + // Then the content gives way to the empty state, so it does not show a filter missing from the list + expect(screen.getByText('No filters selected')).toBeOnTheScreen(); + }); + + it('shows the empty state when Describe your search is shown and the search matches nothing', () => { + // Given Describe your search shown + pressDescribeSearch(); + + // When a search that neither it nor any filter matches is typed + search('unicorn'); + + // Then its row and content go and the empty state shows, as for any filter the search hides + expect(screen.queryByLabelText('Describe your search')).toBeNull(); + expect(screen.queryByTestId('describe-search-content')).toBeNull(); + expect(screen.getByText('No filters selected')).toBeOnTheScreen(); + }); + + it('moves from Describe your search to the first match when the search hides it', () => { + // Given Describe your search shown + pressDescribeSearch(); + + // When a search that only filters match is typed + search('status'); + + // Then Status, the first match, takes over the content + expect(mockSelectedFilter).toBe(FILTER_KEYS.STATUS); + expect(screen.queryByTestId('describe-search-content')).toBeNull(); + }); + + it('keeps Describe your search shown while the search still matches it', () => { + // Given Describe your search shown + pressDescribeSearch(); + + // When a search that matches it is typed + search('describe'); + + // Then it stays, because the search has not hidden it + expect(screen.getByTestId('describe-search-content')).toBeOnTheScreen(); + }); + + it('opens on Describe your search without taking the focus from the filter search', () => { + // Given the popover just opened + // Then Describe your search, the first row, is shown, and its input leaves the focus where the popover put it + expect(mockSelectedFilter).toBeUndefined(); + expect(screen.getByTestId('describe-search-content')).toBeOnTheScreen(); + expect(mockIsDescribeSearchAutoFocused).toBe(false); + }); + + it('opens Describe your search as the first match', () => { + // Given Type shown + hoverAndRest(FILTER_KEYS.TYPE); + + // When a search that Describe your search matches is typed + search('describe'); + + // Then its content takes over, like any other first match + expect(screen.getByTestId('describe-search-content')).toBeOnTheScreen(); + }); }); diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index e0e24886ebe4..468b6026c3a6 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -16436,3 +16436,80 @@ describe('getDatePresets', () => { expect(presets).not.toContain(CONST.SEARCH.DATE_PRESETS.NEVER); }); }); + +describe('isFilterMatchingSearchValue', () => { + beforeAll(async () => { + await IntlStore.load('en'); + }); + + it('matches a filter whose label contains the search value, ignoring case and surrounding spaces', () => { + // Given a search value in a different case from the label, typed with spaces around it + // When the Paid by filter is checked against it + const isMatching = SearchUIUtils.isFilterMatchingSearchValue(CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_BY, CONST.SEARCH.DATA_TYPES.EXPENSE, ' PAID ', translateLocal); + + // Then it matches, because "Paid by" contains "paid" + expect(isMatching).toBe(true); + }); + + it('does not match a filter whose label does not contain the search value', () => { + // Given a search value the Status label does not contain + // When the Status filter is checked against it + const isMatching = SearchUIUtils.isFilterMatchingSearchValue(CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS, CONST.SEARCH.DATA_TYPES.EXPENSE, 'paid', translateLocal); + + // Then it does not match, so the popover hides the Status content while this search is typed + expect(isMatching).toBe(false); + }); + + it('matches the label shown for the type, not the default one', () => { + // Given the date filter, which is labelled "Created date" for invoices and "Date" for expenses + // When it is checked against "created" for both types + const isMatchingForInvoice = SearchUIUtils.isFilterMatchingSearchValue(CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, CONST.SEARCH.DATA_TYPES.INVOICE, 'created', translateLocal); + const isMatchingForExpense = SearchUIUtils.isFilterMatchingSearchValue(CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, CONST.SEARCH.DATA_TYPES.EXPENSE, 'created', translateLocal); + + // Then only the invoice one matches, because the search reads the label the user sees + expect(isMatchingForInvoice).toBe(true); + expect(isMatchingForExpense).toBe(false); + }); +}); + +describe('getMatchingFilters', () => { + const {TYPE, STATUS, PAID, PAID_BY, SUBMITTED} = CONST.SEARCH.SYNTAX_FILTER_KEYS; + + beforeAll(async () => { + await IntlStore.load('en'); + }); + + it('keeps only the filters whose label contains the search value and drops the sections left empty', () => { + // Given two sections, only the second of which holds filters labelled with "paid" + const filters = [ + [TYPE, STATUS], + [SUBMITTED, PAID, PAID_BY], + ]; + + // When they are matched against "PAID" + const matchingFilters = SearchUIUtils.getMatchingFilters(filters, CONST.SEARCH.DATA_TYPES.EXPENSE, 'PAID', translateLocal); + + // Then the paid filters are left in their order and the first section is gone, so no divider shows above them + expect(matchingFilters).toEqual([[PAID, PAID_BY]]); + }); + + it('matches the words of the search value in any order', () => { + // Given filters whose labels share the word "paid" + const filters = [[SUBMITTED, PAID, PAID_BY]]; + + // When they are matched against the words of "Paid by" swapped around + const matchingFilters = SearchUIUtils.getMatchingFilters(filters, CONST.SEARCH.DATA_TYPES.EXPENSE, 'by paid', translateLocal); + + // Then only Paid by is kept, because only its label holds both words + expect(matchingFilters).toEqual([[PAID_BY]]); + }); + + it('returns no section when no filter label contains the search value', () => { + // Given a search value no filter label contains + // When the filters are matched against it + const matchingFilters = SearchUIUtils.getMatchingFilters([[TYPE, STATUS]], CONST.SEARCH.DATA_TYPES.EXPENSE, 'unicorn', translateLocal); + + // Then nothing is left, so the list shows its no results message + expect(matchingFilters).toEqual([]); + }); +}); diff --git a/tests/unit/hooks/useAdvancedSearchFilters.test.ts b/tests/unit/hooks/useAdvancedSearchFilters.test.ts index 70d57b41eaa3..329330a3c976 100644 --- a/tests/unit/hooks/useAdvancedSearchFilters.test.ts +++ b/tests/unit/hooks/useAdvancedSearchFilters.test.ts @@ -540,4 +540,40 @@ describe('useAdvancedSearchFilters', () => { }); }); }); + + describe('section order', () => { + it('groups expense filters into sections and keeps Keyword with the core filters', async () => { + // Given no cards, so the card transactions section has nothing to show + // When the expense filters are listed + const {result} = renderHook(() => useAdvancedSearchFilters(CONST.SEARCH.DATA_TYPES.EXPENSE), {wrapper}); + + // Then Keyword closes the core section, the empty card section is dropped instead of leaving a stray divider, and report actions keep their order + await waitFor(() => { + expect(result.current.at(0)?.at(-1)).toBe(CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD); + expect(result.current).toHaveLength(6); + expect(result.current.flat()).not.toContain(CONST.SEARCH.SYNTAX_FILTER_KEYS.POSTED); + expect(result.current.at(3)).toEqual([ + CONST.SEARCH.SYNTAX_FILTER_KEYS.SUBMITTED, + CONST.SEARCH.SYNTAX_FILTER_KEYS.DECLINED, + CONST.SEARCH.SYNTAX_FILTER_KEYS.HELD, + CONST.SEARCH.SYNTAX_FILTER_KEYS.APPROVED, + CONST.SEARCH.SYNTAX_FILTER_KEYS.ANY_APPROVAL, + CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID, + CONST.SEARCH.SYNTAX_FILTER_KEYS.PAID_BY, + ]); + }); + }); + + it('leads the invoice details with the amounts and gives report ID its own section', async () => { + // Given no cards, so the invoice card transactions section has nothing to show + // When the invoice filters are listed + const {result} = renderHook(() => useAdvancedSearchFilters(CONST.SEARCH.DATA_TYPES.INVOICE), {wrapper}); + + // Then Total and Amount lead the details section, and Report ID sits alone right after it + await waitFor(() => { + expect(result.current.at(1)?.slice(0, 2)).toEqual([CONST.SEARCH.SYNTAX_FILTER_KEYS.TOTAL, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT]); + expect(result.current.at(2)).toEqual([CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_ID]); + }); + }); + }); });