diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 7d37ca62ac30..2898ddc0a143 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7338,6 +7338,7 @@ const CONST = { [this.TABLE_COLUMNS.GROUP_WITHDRAWAL_STATUS]: 'group-withdrawal-status', }; }, + NOT_PREFIX: '-', NOT_MODIFIER: 'Not', DATE_MODIFIERS: { ON: 'On', diff --git a/src/components/Search/FilterComponents/AdvancedFilters/AmountFilterContent.tsx b/src/components/Search/FilterComponents/AdvancedFilters/AmountFilterContent.tsx index af6a259ec49c..058b7b8ee6a6 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/AmountFilterContent.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/AmountFilterContent.tsx @@ -26,7 +26,7 @@ import {View} from 'react-native'; const BETWEEN_MODIFIER = 'Between'; type AmountFilterContentProps = { - filterKey: SearchAmountFilterKeys; + baseFilterKey: SearchAmountFilterKeys; value: SearchAmountValues; largeButton?: boolean; autoFocus?: boolean; @@ -41,7 +41,7 @@ type AmountFilterHandle = { type AmountInputProps = { ref: React.Ref; - filterKey: SearchAmountFilterKeys; + baseFilterKey: SearchAmountFilterKeys; modifier: ValueOf; value: string; label: string; @@ -56,7 +56,7 @@ function getFrontendAmount(amount: string | undefined) { return amount ? convertToFrontendAmountAsString(Number(amount), CONST.DEFAULT_CURRENCY_DECIMALS) : ''; } -function AmountInput({ref, filterKey, modifier, value, label, autoFocus}: AmountInputProps) { +function AmountInput({ref, baseFilterKey, modifier, value, label, autoFocus}: AmountInputProps) { const styles = useThemeStyles(); const [amount, setAmount] = useState(value); const {didScreenTransitionEnd} = useScreenWrapperTransitionStatus(); @@ -64,7 +64,7 @@ function AmountInput({ref, filterKey, modifier, value, label, autoFocus}: Amount useImperativeHandle(ref, () => ({ getValue: () => { - const key = `${filterKey}${modifier}`; + const key = `${baseFilterKey}${modifier}`; return {[key]: getBackendAmount(amount)}; }, })); @@ -93,13 +93,13 @@ function AmountInput({ref, filterKey, modifier, value, label, autoFocus}: Amount type AmountBetweenInputProps = { ref: React.Ref; - filterKey: SearchAmountFilterKeys; + baseFilterKey: SearchAmountFilterKeys; greaterThanValue: string; lessThanValue: string; autoFocus?: boolean; }; -function AmountBetweenInput({ref, filterKey, greaterThanValue, lessThanValue, autoFocus}: AmountBetweenInputProps) { +function AmountBetweenInput({ref, baseFilterKey, greaterThanValue, lessThanValue, autoFocus}: AmountBetweenInputProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const [greaterThanAmount, setGreaterThanAmount] = useState(greaterThanValue); @@ -109,8 +109,8 @@ function AmountBetweenInput({ref, filterKey, greaterThanValue, lessThanValue, au useImperativeHandle(ref, () => ({ getValue: () => { - const greaterThanKey = `${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN}`; - const lessThanKey = `${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN}`; + const greaterThanKey = `${baseFilterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN}`; + const lessThanKey = `${baseFilterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN}`; return {[greaterThanKey]: getBackendAmount(greaterThanAmount), [lessThanKey]: getBackendAmount(lessThanAmount)}; }, })); @@ -152,7 +152,7 @@ function AmountBetweenInput({ref, filterKey, greaterThanValue, lessThanValue, au ); } -function AmountFilterContent({filterKey, value, autoFocus, largeButton, style, onChange}: AmountFilterContentProps) { +function AmountFilterContent({baseFilterKey, value, autoFocus, largeButton, style, onChange}: AmountFilterContentProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -184,9 +184,9 @@ function AmountFilterContent({filterKey, value, autoFocus, largeButton, style, o } const formValues: AmountFilterValues = {}; - formValues[`${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.EQUAL_TO}`] = undefined; - formValues[`${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN}`] = undefined; - formValues[`${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN}`] = undefined; + formValues[`${baseFilterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.EQUAL_TO}`] = undefined; + formValues[`${baseFilterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN}`] = undefined; + formValues[`${baseFilterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN}`] = undefined; onChange({ ...formValues, ...inputRef.current.getValue(), @@ -203,7 +203,7 @@ function AmountFilterContent({filterKey, value, autoFocus, largeButton, style, o {keyForList: CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN, text: translate('search.filters.amount.lessThan'), isSelected: selectedModifier === CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN}, {keyForList: BETWEEN_MODIFIER, text: translate('search.filters.amount.between'), isSelected: selectedModifier === BETWEEN_MODIFIER}, ]; - const label = translate(FILTER_VIEW_MAP[filterKey].labelKey); + const label = translate(FILTER_VIEW_MAP[baseFilterKey].labelKey); return ( @@ -226,7 +226,7 @@ function AmountFilterContent({filterKey, value, autoFocus, largeButton, style, o (config.keyForList === BETWEEN_MODIFIER ? ( void; }; -function DateFilterContent({filterKey, value, selectedDateModifier, hasFeed, largeButton, style, onDateModifierSelected, onChange}: DateFilterContentProps) { +function DateFilterContent({baseFilterKey, value, selectedDateModifier, hasFeed, largeButton, style, onDateModifierSelected, onChange}: DateFilterContentProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const dateFilterRef = useRef(null); @@ -54,7 +54,7 @@ function DateFilterContent({filterKey, value, selectedDateModifier, hasFeed, lar selectedDateModifier={selectedDateModifier} onSelectDateModifier={onDateModifierSelected} defaultDateValues={value} - presets={getDatePresets(filterKey, hasFeed)} + presets={getDatePresets(baseFilterKey, hasFeed)} onSubmit={onChange} shouldShowActionButtons={false} /> diff --git a/src/components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent.tsx b/src/components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent.tsx index bd347765d06e..586835b5a9e8 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent.tsx @@ -1,3 +1,6 @@ +import type {ListFilterContentProps} from '@components/Search/FilterComponents/ListFilterContent'; + +import {getFilterFormValues} from '@libs/SearchQueryUtils'; import {getFilterNegatableValue, isAmountFilterKey, isDateFilterKey, isTextFilterKey} from '@libs/SearchUIUtils'; import type {SearchFilter} from '@libs/SearchUIUtils'; @@ -6,19 +9,21 @@ import type {SearchAdvancedFiltersForm} from '@src/types/form'; import React from 'react'; -import type {FilterComponentsProps} from '..'; import type {AmountFilterContentProps} from './AmountFilterContent'; import type {DateFilterContentProps} from './DateFilterContent'; import type {ReportFieldFilterContentProps} from './ReportFieldFilterContent'; import type {TextInputFilterContentProps} from './TextInputFilterContent'; -type TextInputFilterContentWrapperProps = Pick; -type AmountFilterContentWrapperProps = Pick; -type DateFilterContentWrapperProps = Pick; +type TextInputFilterContentWrapperProps = Pick; +type AmountFilterContentWrapperProps = Pick; +type DateFilterContentWrapperProps = Pick; type ReportFieldFilterContentWrapperProps = Pick; -type CommonFilterContentWrapperProps = Omit; +type ListFilterContentWrapperProps = Omit & { + onChange: (value: ListFilterContentProps['value'], isNegated: boolean) => void; +}; + type SearchAdvancedFiltersContentProps = { - filterKey: SearchFilter['key']; + baseFilterKey: SearchFilter['key']; values: Partial | undefined; ready?: boolean; components: { @@ -26,47 +31,30 @@ type SearchAdvancedFiltersContentProps = { Amount: React.ComponentType; Date: React.ComponentType; ReportField: React.ComponentType; - Common: React.ComponentType; + List: React.ComponentType; }; onChange: (values: Partial) => void; }; -function getFilterFormValue(filterKey: K, value: SearchAdvancedFiltersForm[K] | undefined): Partial { - const update: Partial = {}; - update[filterKey] = value; - return update; -} - -function SearchAdvancedFiltersContent({filterKey, values, ready, components, onChange}: SearchAdvancedFiltersContentProps) { - const {Text: TextFilter, Amount: AmountFilter, Date: DateFilter, ReportField: ReportFieldFilter, Common: CommonFilter} = components; - - if (isTextFilterKey(filterKey)) { - return ( - onChange({[filterKey]: newValue})} - /> - ); - } - - if (isAmountFilterKey(filterKey)) { +function SearchAdvancedFiltersContent({baseFilterKey, values, ready, components, onChange}: SearchAdvancedFiltersContentProps) { + if (isAmountFilterKey(baseFilterKey)) { + const AmountFilter = components.Amount; return ( ); } - if (isDateFilterKey(filterKey)) { + if (isDateFilterKey(baseFilterKey)) { + const DateFilter = components.Date; const onModifier = CONST.SEARCH.DATE_MODIFIERS.ON; const afterModifier = CONST.SEARCH.DATE_MODIFIERS.AFTER; const beforeModifier = CONST.SEARCH.DATE_MODIFIERS.BEFORE; @@ -74,28 +62,29 @@ function SearchAdvancedFiltersContent({filterKey, values, ready, components, onC return ( onChange({ - [`${filterKey}${onModifier}`]: newValues[onModifier], - [`${filterKey}${afterModifier}`]: newValues[afterModifier], - [`${filterKey}${beforeModifier}`]: newValues[beforeModifier], - [`${filterKey}${rangeModifier}`]: newValues[rangeModifier], + [`${baseFilterKey}${onModifier}`]: newValues[onModifier], + [`${baseFilterKey}${afterModifier}`]: newValues[afterModifier], + [`${baseFilterKey}${beforeModifier}`]: newValues[beforeModifier], + [`${baseFilterKey}${rangeModifier}`]: newValues[rangeModifier], }) } /> ); } - if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_FIELD) { + if (baseFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_FIELD) { + const ReportFieldFilter = components.ReportField; return ( onChange(getFilterFormValues(baseFilterKey, newValue, negated))} + /> + ); + } + + const {isNegated, value} = getFilterNegatableValue(baseFilterKey, values); + const ListFilter = components.List; return ( - onChange(getFilterFormValue(filterKey, newValue))} + isNegated={isNegated} + onChange={(newValue, negated) => onChange(getFilterFormValues(baseFilterKey, newValue, negated))} /> ); } export default SearchAdvancedFiltersContent; -export type {TextInputFilterContentWrapperProps, AmountFilterContentWrapperProps, DateFilterContentWrapperProps, ReportFieldFilterContentWrapperProps, CommonFilterContentWrapperProps}; +export type {TextInputFilterContentWrapperProps, AmountFilterContentWrapperProps, DateFilterContentWrapperProps, ReportFieldFilterContentWrapperProps, ListFilterContentWrapperProps}; diff --git a/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx b/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx index cadc0d8dab00..7963769a44ff 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx @@ -1,5 +1,7 @@ import Button from '@components/Button'; +import NegatableFilter from '@components/Search/FilterComponents/NegatableFilter'; import useTextFilterValidation from '@components/Search/hooks/useTextFilterValidation'; +import type {ReportFieldTextKey, SearchTextFilterKeys} from '@components/Search/types'; import TextInput from '@components/TextInput'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; @@ -17,51 +19,53 @@ import React, {useState} from 'react'; import {View} from 'react-native'; type TextInputFilterContentProps = { - filterKey: - | typeof CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT - | typeof CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION - | typeof CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_ID - | typeof CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD - | typeof CONST.SEARCH.SYNTAX_FILTER_KEYS.TITLE - | typeof CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_ID; + baseFilterKey: Exclude; value: string | undefined; + isNegated: boolean; largeButton?: boolean; autoFocus?: boolean; style?: StyleProp; - onChange: (value: string | undefined) => void; + onChange: (value: string | undefined, isNegated: boolean) => void; }; function isTextInput(element: BaseTextInputRef | RNTextInput | null): element is RNTextInput { return !!element && 'isFocused' in element; } -function TextInputFilterContent({filterKey, value: initialValue, autoFocus, largeButton, style, onChange}: TextInputFilterContentProps) { +function TextInputFilterContent({baseFilterKey, value: initialValue, isNegated: initialIsNegated, autoFocus, largeButton, style, onChange}: TextInputFilterContentProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const [value, setValue] = useState(initialValue); + const [isNegated, setIsNegated] = useState(initialIsNegated); - const label = translate(FILTER_VIEW_MAP[filterKey].labelKey); + const label = translate(FILTER_VIEW_MAP[baseFilterKey].labelKey); const {inputCallbackRef} = useAutoFocusInput(); - const error = useTextFilterValidation(filterKey, value); + const error = useTextFilterValidation(baseFilterKey, value); return ( - { - if (!autoFocus || !isTextInput(ref)) { - return; - } - inputCallbackRef(ref); - }} - placeholder={label} - value={value} - errorText={error} - hasError={!!error} - onChangeText={setValue} - accessibilityLabel={label} - role={CONST.ROLE.PRESENTATION} - containerStyles={[styles.ph5]} - /> + + { + if (!autoFocus || !isTextInput(ref)) { + return; + } + inputCallbackRef(ref); + }} + placeholder={label} + value={value} + errorText={error} + hasError={!!error} + onChangeText={setValue} + accessibilityLabel={label} + role={CONST.ROLE.PRESENTATION} + containerStyles={[styles.ph5]} + /> + + + + + {children} + + ); +} + +export default NegatableFilter; diff --git a/src/components/Search/FilterDropdowns/AmountPopup.tsx b/src/components/Search/FilterDropdowns/AmountPopup.tsx index b1c80626eee3..9dd35292c8b0 100644 --- a/src/components/Search/FilterDropdowns/AmountPopup.tsx +++ b/src/components/Search/FilterDropdowns/AmountPopup.tsx @@ -17,7 +17,7 @@ import React, {useState} from 'react'; import BasePopup from './BasePopup'; type AmountPopupProps = { - filterKey: SearchAmountFilterKeys; + baseFilterKey: SearchAmountFilterKeys; label: string; value: SearchAmountValues; updateFilterForm: (value: Partial) => void; @@ -59,7 +59,7 @@ function AmountInput({title, value, name, onSave, onBackButtonPress}: AmountInpu ); } -function AmountPopup({filterKey, label, value, closeOverlay, updateFilterForm}: AmountPopupProps) { +function AmountPopup({baseFilterKey, label, value, closeOverlay, updateFilterForm}: AmountPopupProps) { const {translate} = useLocalize(); const [selectedModifier, setSelectedModifier] = useState | null>(null); const [amountValues, setAmountValues] = useState(value); @@ -106,7 +106,7 @@ function AmountPopup({filterKey, label, value, closeOverlay, updateFilterForm}: @@ -115,9 +115,9 @@ function AmountPopup({filterKey, label, value, closeOverlay, updateFilterForm}: const onChange = (values: SearchAmountValues) => { const formValues: Record = {}; - formValues[`${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.EQUAL_TO}`] = values[CONST.SEARCH.AMOUNT_MODIFIERS.EQUAL_TO]; - formValues[`${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN}`] = values[CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN]; - formValues[`${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN}`] = values[CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN]; + formValues[`${baseFilterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.EQUAL_TO}`] = values[CONST.SEARCH.AMOUNT_MODIFIERS.EQUAL_TO]; + formValues[`${baseFilterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN}`] = values[CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN]; + formValues[`${baseFilterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN}`] = values[CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN]; updateFilterForm(formValues); closeOverlay(); }; diff --git a/src/components/Search/FilterDropdowns/CommonPopup.tsx b/src/components/Search/FilterDropdowns/CommonPopup.tsx deleted file mode 100644 index b676515806f8..000000000000 --- a/src/components/Search/FilterDropdowns/CommonPopup.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import FilterComponents from '@components/Search/FilterComponents'; -import type {FilterComponentsProps} from '@components/Search/FilterComponents'; - -import type {SearchAdvancedFiltersForm} from '@src/types/form/SearchAdvancedFiltersForm'; - -import React, {useState} from 'react'; - -import type {PopoverComponentProps} from './FilterPopupButton'; - -import BasePopup from './BasePopup'; - -type CommonPopupProps = { - filterKey: FilterComponentsProps['filterKey']; - value: FilterComponentsProps['value']; - type: FilterComponentsProps['type']; - policyID: FilterComponentsProps['policyID']; - label: string; - closeOverlay: PopoverComponentProps['closeOverlay']; - updateFilterForm: (value: Partial) => void; -}; - -function CommonPopup({filterKey, value: initialValue, type, policyID, label, updateFilterForm, closeOverlay}: CommonPopupProps) { - const [value, setValue] = useState(initialValue); - - const applyChanges = () => { - updateFilterForm({[filterKey]: value} as Partial); - closeOverlay(); - }; - - return ( - - - - ); -} - -export default CommonPopup; diff --git a/src/components/Search/FilterDropdowns/ListPopup.tsx b/src/components/Search/FilterDropdowns/ListPopup.tsx new file mode 100644 index 000000000000..b4f9c36f304d --- /dev/null +++ b/src/components/Search/FilterDropdowns/ListPopup.tsx @@ -0,0 +1,52 @@ +import ListFilterContent from '@components/Search/FilterComponents/ListFilterContent'; +import type {ListFilterContentProps} from '@components/Search/FilterComponents/ListFilterContent'; + +import {getFilterFormValues} from '@libs/SearchQueryUtils'; +import {getFilterNegatableValue} from '@libs/SearchUIUtils'; + +import CONST from '@src/CONST'; +import type {SearchAdvancedFiltersForm} from '@src/types/form/SearchAdvancedFiltersForm'; + +import React, {useState} from 'react'; + +import type {PopoverComponentProps} from './FilterPopupButton'; + +import BasePopup from './BasePopup'; + +type ListPopupProps = Pick & { + values: Partial | undefined; + label: string; + closeOverlay: PopoverComponentProps['closeOverlay']; + updateFilterForm: (value: Partial) => void; +}; + +function ListPopup({baseFilterKey, values, label, updateFilterForm, closeOverlay}: ListPopupProps) { + const {isNegated: initialIsNegated, value: initialValue} = getFilterNegatableValue(baseFilterKey, values); + const [value, setValue] = useState(initialValue); + const [isNegated, setIsNegated] = useState(initialIsNegated); + + const applyChanges = () => { + updateFilterForm(getFilterFormValues(baseFilterKey, value, isNegated)); + closeOverlay(); + }; + + return ( + + + + ); +} + +export default ListPopup; diff --git a/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/AmountFilterContentPopupWrapper.tsx b/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/AmountFilterContentPopupWrapper.tsx index faa9c64a40aa..567570f1d0fe 100644 --- a/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/AmountFilterContentPopupWrapper.tsx +++ b/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/AmountFilterContentPopupWrapper.tsx @@ -5,11 +5,11 @@ import useThemeStyles from '@hooks/useThemeStyles'; import React from 'react'; -function AmountFilterContentPopupWrapper({filterKey, value, onChange}: AmountFilterContentWrapperProps) { +function AmountFilterContentPopupWrapper({baseFilterKey, value, onChange}: AmountFilterContentWrapperProps) { const styles = useThemeStyles(); return ( - ); -} - -export default CommonFilterContentPopupWrapper; diff --git a/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/DateFilterContentPopupWrapper.tsx b/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/DateFilterContentPopupWrapper.tsx index 3328083dadc1..a36cc1e44202 100644 --- a/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/DateFilterContentPopupWrapper.tsx +++ b/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/DateFilterContentPopupWrapper.tsx @@ -8,14 +8,14 @@ import type {SearchDateModifier} from '@libs/SearchUIUtils'; import React, {useState} from 'react'; import {View} from 'react-native'; -function DateFilterContentPopupWrapper({filterKey, value, hasFeed, onChange}: DateFilterContentWrapperProps) { +function DateFilterContentPopupWrapper({baseFilterKey, value, hasFeed, onChange}: DateFilterContentWrapperProps) { const styles = useThemeStyles(); const [selectedDateModifier, setSelectedDateModifier] = useState(null); return ( onChange(newValue, isNegated)} + onNegationChange={(negated) => { + setIsNegated(negated); + if (!value?.length) { + return; + } + onChange(value, negated); + }} + /> + ); +} + +export default ListFilterContentPopupWrapper; diff --git a/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/TextInputFilterContentPopupWrapper.tsx b/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/TextInputFilterContentPopupWrapper.tsx index 3c86c272b97a..acd3dd16c586 100644 --- a/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/TextInputFilterContentPopupWrapper.tsx +++ b/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/TextInputFilterContentPopupWrapper.tsx @@ -3,15 +3,20 @@ import TextInputFilterContent from '@components/Search/FilterComponents/Advanced import useThemeStyles from '@hooks/useThemeStyles'; +import {isFilterNegatable} from '@libs/SearchQueryUtils'; + import React from 'react'; -function TextInputFilterContentPopupWrapper({filterKey, value, onChange}: TextInputFilterContentWrapperProps) { +function TextInputFilterContentPopupWrapper({baseFilterKey, value, isNegated, onChange}: TextInputFilterContentWrapperProps) { const styles = useThemeStyles(); + const isFilterKeyNegatable = isFilterNegatable(baseFilterKey); + return ( ); diff --git a/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx b/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx index 08e4ac8fcb4f..eadcc3d4e5c9 100644 --- a/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx +++ b/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx @@ -19,8 +19,8 @@ import React, {useRef, useState} from 'react'; import {View} from 'react-native'; import AmountFilterContentPopupWrapper from './AmountFilterContentPopupWrapper'; -import CommonFilterContentPopupWrapper from './CommonFilterContentPopupWrapper'; import DateFilterContentPopupWrapper from './DateFilterContentPopupWrapper'; +import ListFilterContentPopupWrapper from './ListFilterContentPopupWrapper'; import ReportFieldFilterContentPopupWrapper from './ReportFieldFilterContentPopupWrapper'; import TextInputFilterContentPopupWrapper from './TextInputFilterContentPopupWrapper'; @@ -55,9 +55,9 @@ function SearchAdvancedFiltersPopup({queryJSON}: SearchAdvancedFiltersPopupProps > ; + values: Partial | undefined; label: string; closeOverlay: PopoverComponentProps['closeOverlay']; updateFilterForm: (value: Partial) => void; }; -function TextFilterPopup({filterKey, value: initialValue, label, updateFilterForm, closeOverlay}: TextFilterPopupProps) { +function TextFilterPopup({baseFilterKey, values, label, updateFilterForm, closeOverlay}: TextFilterPopupProps) { const styles = useThemeStyles(); + + const {isNegated: initialIsNegated, value: initialValue} = getFilterNegatableValue(baseFilterKey, values); + const [isNegated, setIsNegated] = useState(initialIsNegated); const [value, setValue] = useState(initialValue); - const error = useTextFilterValidation(filterKey, value); + const error = useTextFilterValidation(baseFilterKey, value); const applyChanges = () => { if (error) { return; } - updateFilterForm({[filterKey]: value} as Partial); + updateFilterForm(getFilterFormValues(baseFilterKey, value, isNegated)); closeOverlay(); }; @@ -39,18 +46,24 @@ function TextFilterPopup({filterKey, value: initialValue, label, updateFilterFor - + + + ); } diff --git a/src/components/Search/SearchPageHeader/DatePickerFilterPopup.tsx b/src/components/Search/SearchPageHeader/DatePickerFilterPopup.tsx index a52f67f2527d..0fab145d9e83 100644 --- a/src/components/Search/SearchPageHeader/DatePickerFilterPopup.tsx +++ b/src/components/Search/SearchPageHeader/DatePickerFilterPopup.tsx @@ -11,20 +11,20 @@ import type {SearchAdvancedFiltersForm} from '@src/types/form'; import React from 'react'; type DatePickerFilterPopupProps = Pick & { - filterKey: SearchDateFilterKeys; + baseFilterKey: SearchDateFilterKeys; value: SearchDateValues; label: string; hasFeed: boolean; updateFilterForm: (values: Partial) => void; }; -function DatePickerFilterPopup({closeOverlay, setPopoverWidth, filterKey, value, label, hasFeed, updateFilterForm}: DatePickerFilterPopupProps) { +function DatePickerFilterPopup({closeOverlay, setPopoverWidth, baseFilterKey, value, label, hasFeed, updateFilterForm}: DatePickerFilterPopupProps) { const onChange = (selectedDates: SearchDateValues) => { const dateFormValues: Record = {}; - dateFormValues[`${filterKey}On`] = selectedDates[CONST.SEARCH.DATE_MODIFIERS.ON]; - dateFormValues[`${filterKey}After`] = selectedDates[CONST.SEARCH.DATE_MODIFIERS.AFTER]; - dateFormValues[`${filterKey}Before`] = selectedDates[CONST.SEARCH.DATE_MODIFIERS.BEFORE]; - dateFormValues[`${filterKey}Range`] = selectedDates[CONST.SEARCH.DATE_MODIFIERS.RANGE]; + dateFormValues[`${baseFilterKey}On`] = selectedDates[CONST.SEARCH.DATE_MODIFIERS.ON]; + dateFormValues[`${baseFilterKey}After`] = selectedDates[CONST.SEARCH.DATE_MODIFIERS.AFTER]; + dateFormValues[`${baseFilterKey}Before`] = selectedDates[CONST.SEARCH.DATE_MODIFIERS.BEFORE]; + dateFormValues[`${baseFilterKey}Range`] = selectedDates[CONST.SEARCH.DATE_MODIFIERS.RANGE]; updateFilterForm(dateFormValues as Partial); }; return ( @@ -34,7 +34,7 @@ function DatePickerFilterPopup({closeOverlay, setPopoverWidth, filterKey, value, onChange={onChange} closeOverlay={closeOverlay} setPopoverWidth={setPopoverWidth} - presets={getDatePresets(filterKey, hasFeed)} + presets={getDatePresets(baseFilterKey, hasFeed)} /> ); } diff --git a/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx b/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx index 00cb525f14c2..4683a486510a 100644 --- a/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx +++ b/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx @@ -1,7 +1,7 @@ import {ListFilterHeightContextProvider} from '@components/Search/FilterComponents/ListFilterHeightContext'; import AmountPopup from '@components/Search/FilterDropdowns/AmountPopup'; -import CommonPopup from '@components/Search/FilterDropdowns/CommonPopup'; import type {PopoverComponentProps} from '@components/Search/FilterDropdowns/FilterPopupButton'; +import ListPopup from '@components/Search/FilterDropdowns/ListPopup'; import ReportFieldPopup from '@components/Search/FilterDropdowns/ReportFieldPopup'; import TextFilterPopup from '@components/Search/FilterDropdowns/TextFilterPopup'; import useUpdateFilterQuery from '@components/Search/hooks/useUpdateFilterQuery'; @@ -15,8 +15,8 @@ import useOnyx from '@hooks/useOnyx'; import {close} from '@libs/actions/Modal'; import {setSearchContext} from '@libs/actions/Search'; -import {getAdvancedFiltersToReset} from '@libs/SearchQueryUtils'; -import {FILTER_VIEW_MAP, getFilterNegatableValue, isAmountFilterKey, isDateFilterKey, isTextFilterKey, mapFiltersFormToLabelValueList, SKIPPED_SEARCH_FILTERS} from '@libs/SearchUIUtils'; +import {getAdvancedFiltersToReset, removeNegation} from '@libs/SearchQueryUtils'; +import {FILTER_VIEW_MAP, isAmountFilterKey, isDateFilterKey, isReportFieldKey, isTextFilterKey, mapFiltersFormToLabelValueList, SKIPPED_SEARCH_FILTERS} from '@libs/SearchUIUtils'; import type {SearchFilter} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; @@ -45,7 +45,7 @@ type UseSearchFiltersBarResult = { }; type FilterPopupProps = { - filterKey: SearchFilter['key']; + baseFilterKey: SearchFilter['key']; searchAdvancedFiltersForm: Partial; closeOverlay: () => void; setPopoverWidth: PopoverComponentProps['setPopoverWidth']; @@ -56,23 +56,23 @@ function getFilterSentryLabel(filterKey: SearchAdvancedFiltersKey | SearchFilter return `Search-Filter-${filterKey}`; } -function FilterPopup({filterKey, searchAdvancedFiltersForm, closeOverlay, setPopoverWidth, updateFilterForm}: FilterPopupProps) { +function FilterPopup({baseFilterKey, searchAdvancedFiltersForm, closeOverlay, setPopoverWidth, updateFilterForm}: FilterPopupProps) { const {translate} = useLocalize(); - const label = translate(FILTER_VIEW_MAP[filterKey].labelKey); + const label = translate(FILTER_VIEW_MAP[baseFilterKey].labelKey); const closeModalAndUpdateFilterForm = (values: Partial) => { close(() => updateFilterForm(values)); }; - if (isAmountFilterKey(filterKey)) { + if (isAmountFilterKey(baseFilterKey)) { const value = { - [CONST.SEARCH.AMOUNT_MODIFIERS.EQUAL_TO]: searchAdvancedFiltersForm[`${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.EQUAL_TO}`], - [CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN]: searchAdvancedFiltersForm[`${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN}`], - [CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN]: searchAdvancedFiltersForm[`${filterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN}`], + [CONST.SEARCH.AMOUNT_MODIFIERS.EQUAL_TO]: searchAdvancedFiltersForm[`${baseFilterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.EQUAL_TO}`], + [CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN]: searchAdvancedFiltersForm[`${baseFilterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.GREATER_THAN}`], + [CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN]: searchAdvancedFiltersForm[`${baseFilterKey}${CONST.SEARCH.AMOUNT_MODIFIERS.LESS_THAN}`], }; return ( ( + const filters = mapFiltersFormToLabelValueList( searchAdvancedFiltersForm, SKIPPED_SEARCH_FILTERS, translate, localeCompare, convertToDisplayStringWithoutCurrency, - (filterKey) => ({ + (filterKey): FilterItem => ({ PopoverComponent: ({closeOverlay, setPopoverWidth}) => ( { - if (curr.startsWith(CONST.SEARCH.REPORT_FIELD.GLOBAL_PREFIX)) { - acc[curr as SearchAdvancedFiltersKey] = undefined; + if (isReportFieldKey(curr)) { + acc[curr] = undefined; } return acc; }, {} as Partial); diff --git a/src/components/Search/hooks/useUpdateFilterQuery.tsx b/src/components/Search/hooks/useUpdateFilterQuery.tsx index 08dac155a641..4dff2aefe8c2 100644 --- a/src/components/Search/hooks/useUpdateFilterQuery.tsx +++ b/src/components/Search/hooks/useUpdateFilterQuery.tsx @@ -24,7 +24,9 @@ function useUpdateFilterQuery(queryJSON: SearchQueryJSON | undefined) { if (updatedFilterFormValues.type !== currentValues.type) { updatedFilterFormValues.columns = []; updatedFilterFormValues.status = undefined; + updatedFilterFormValues.statusNot = undefined; updatedFilterFormValues.has = filterValidHasValues(updatedFilterFormValues.has, updatedFilterFormValues.type, translate); + updatedFilterFormValues.hasNot = filterValidHasValues(updatedFilterFormValues.hasNot, updatedFilterFormValues.type, translate); } if (updatedFilterFormValues.groupBy !== currentValues.groupBy) { diff --git a/src/languages/de.ts b/src/languages/de.ts index ab7159789b5f..6807c1419afd 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8830,6 +8830,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc [CONST.SEARCH.ACTION_FILTERS.PAY]: 'Bezahlen', [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Export', }, + filterType: {label: 'Filtertyp', has: {positive: 'hat', negative: 'hat nicht'}, is: {positive: 'ist', negative: 'ist nicht'}}, }, display: { label: 'Anzeige', diff --git a/src/languages/en.ts b/src/languages/en.ts index b38453141ed1..edf4825a81eb 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8954,6 +8954,17 @@ const translations = { [CONST.SEARCH.ACTION_FILTERS.PAY]: 'Pay', [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Export', }, + filterType: { + label: 'Filter type', + has: { + positive: 'has', + negative: "doesn't have", + }, + is: { + positive: 'is', + negative: 'is not', + }, + }, }, display: { label: 'Display', diff --git a/src/languages/es.ts b/src/languages/es.ts index 80f5e64f2d4c..c56cf0b0db59 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8639,6 +8639,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Exportar', }, reportField: (name, value) => `${name} es ${value}`, + filterType: {label: 'Tipo de filtro', has: {positive: 'tiene', negative: 'no tiene'}, is: {positive: 'es', negative: 'no es'}}, }, chartTitles: { [CONST.SEARCH.GROUP_BY.FROM]: 'De', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 2c80ad56187f..aba6dd7b87a9 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8866,6 +8866,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e [CONST.SEARCH.ACTION_FILTERS.PAY]: 'Payer', [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Exporter', }, + filterType: {label: 'Type de filtre', has: {positive: 'a', negative: 'n’a pas'}, is: {positive: 'est', negative: 'n’est pas'}}, }, display: { label: 'Affichage', diff --git a/src/languages/it.ts b/src/languages/it.ts index c6cce232c27a..91520a9f74f8 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8819,6 +8819,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, [CONST.SEARCH.ACTION_FILTERS.PAY]: 'Paga', [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Esporta', }, + filterType: {label: 'Tipo di filtro', has: {positive: 'ha', negative: 'non ha'}, is: {positive: 'è', negative: 'non è'}}, }, display: { label: 'Visualizza', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index cd0d1dd46a70..98aa8e9a5f4f 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8701,6 +8701,7 @@ ${reportName}`, [CONST.SEARCH.ACTION_FILTERS.PAY]: '支払う', [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'エクスポート', }, + filterType: {label: 'フィルタータイプ', has: {positive: '持っています', negative: '持っていません'}, is: {positive: 'は', negative: 'ではありません'}}, }, display: { label: '表示', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 983cfb709e44..3e93e198f230 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8788,6 +8788,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, [CONST.SEARCH.ACTION_FILTERS.PAY]: 'Betalen', [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Exporteren', }, + filterType: {label: 'Filtertype', has: {positive: 'heeft', negative: 'heeft niet'}, is: {positive: 'is', negative: 'is niet'}}, }, display: { label: 'Weergave', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 8ba7155c1df9..b0f96212950b 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8767,6 +8767,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, [CONST.SEARCH.ACTION_FILTERS.PAY]: 'Zapłać', [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Eksportuj', }, + filterType: {label: 'Typ filtra', has: {positive: 'ma', negative: 'nie ma'}, is: {positive: 'jest', negative: 'nie jest'}}, }, display: { label: 'Wyświetl', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 6e1b8d8a3984..13a61f41613d 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8776,6 +8776,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, [CONST.SEARCH.ACTION_FILTERS.PAY]: 'Pagar', [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Exportar', }, + filterType: {label: 'Tipo de filtro', has: {positive: 'tem', negative: 'não tem'}, is: {positive: 'é', negative: 'não é'}}, }, display: { label: 'Exibir', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 7ad0e99a0c82..21d8f3193006 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8515,6 +8515,7 @@ ${reportName}`, [CONST.SEARCH.ACTION_FILTERS.PAY]: '支付', [CONST.SEARCH.ACTION_FILTERS.EXPORT]: '导出', }, + filterType: {label: '筛选类型', has: {positive: '有', negative: '没有'}, is: {positive: '是', negative: '不是'}}, }, display: { label: '显示', diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index bfb2dc086470..207a0e45f14e 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -1,4 +1,6 @@ import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; +import type {TextInputFilterContentProps} from '@components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent'; +import type {ListFilterContentProps} from '@components/Search/FilterComponents/ListFilterContent'; import type { ASTNode, Filter, @@ -356,7 +358,7 @@ function buildFilterValuesString(filterName: string, queryFilters: QueryFilter[] } else if (index !== 0 && (previousValueHasSameOp || nextValueHasSameOp)) { filterValueString += `${delimiter}${sanitizeSearchValue(queryFilter.value.toString())}`; } else if (queryFilter.operator === CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO) { - filterValueString += ` -${filterName}:${sanitizeSearchValue(queryFilter.value.toString())}`; + filterValueString += ` ${CONST.SEARCH.NOT_PREFIX}${filterName}:${sanitizeSearchValue(queryFilter.value.toString())}`; } else if (queryFilter.operator === CONST.SEARCH.SYNTAX_OPERATORS.RANGE) { const rangeBoundaries = parseRangeQueryValue(queryFilter.value.toString()); if (rangeBoundaries.from) { @@ -545,13 +547,13 @@ function getQueryHashes(query: SearchQueryJSON) { orderedQuery += `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${query.type}`; const status = getFilterFromQuery(query, CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS); - orderedQuery += ` ${status.isNegated ? '-' : ''}${CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS}:${status.value?.join(',') ?? ''}`; + orderedQuery += ` ${status.isNegated ? CONST.SEARCH.NOT_PREFIX : ''}${CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS}:${status.value?.join(',') ?? ''}`; orderedQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY}:${query.groupBy}`; const policyID = getFilterFromQuery(query, CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID); if (policyID.value) { - orderedQuery += ` ${policyID.isNegated ? '-' : ''}${CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID}:${policyID.value.join(',')} `; + orderedQuery += ` ${policyID.isNegated ? CONST.SEARCH.NOT_PREFIX : ''}${CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID}:${policyID.value.join(',')} `; } const filterSet = new Set(orderedQuery); @@ -891,7 +893,7 @@ function buildQueryStringFromFilterFormValues(filterValues: Partial(filterKey: T, isNegated: boolean): T | `$ return isNegated ? `${filterKey}${CONST.SEARCH.NOT_MODIFIER}` : filterKey; } -function removeNegation(filterKey: string) { - return filterKey.replace(CONST.SEARCH.NOT_MODIFIER, ''); +type RemoveNegation = T extends `${infer S}${typeof CONST.SEARCH.NOT_MODIFIER}` ? S : T; + +function removeNegation(filterKey: T): RemoveNegation; +function removeNegation(filterKey: string): string { + return filterKey.endsWith(CONST.SEARCH.NOT_MODIFIER) ? filterKey.slice(0, -CONST.SEARCH.NOT_MODIFIER.length) : filterKey; } function isFilterNegatable(key: SearchAdvancedFiltersKey) { return NEGATABLE_FILTERS.has(removeNegation(key) as SearchNegatableFilterKeys); } +function isFilterNegated(filterKey: SearchAdvancedFiltersKey) { + return filterKey.endsWith(CONST.SEARCH.NOT_MODIFIER); +} + +function getFilterFormValues( + baseFilterKey: K, + value: SearchAdvancedFiltersForm[K] | undefined, + isNegated: boolean, +): Partial { + const update: Partial> = {}; + const negatedFilterKey = addNegation(baseFilterKey, true); + if (isFilterNegatable(baseFilterKey)) { + update[negatedFilterKey] = isNegated ? value : undefined; + update[baseFilterKey] = isNegated ? undefined : value; + } else { + update[negatedFilterKey] = undefined; + update[baseFilterKey] = value; + } + return update; +} + export { getDateRangeDisplayValueFromFormValue, getRangeBoundariesFromFormValue, @@ -2493,8 +2519,11 @@ export { getParamsState, getRoutes, isSearchRootParams, - getFilterFromQuery, + isFilterNegated, isFilterNegatable, + removeNegation, + getFilterFormValues, + getFilterFromQuery, }; export type {BuildUserReadableQueryStringParams}; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 4662b5aae58f..96a3e1c8e29e 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -2,7 +2,8 @@ import type {CurrencyListActionsContextType} from '@components/CurrencyListConte import type {ExpensifyIconName} from '@components/Icon/ExpensifyIconLoader'; import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; import type {MenuItemWithLink} from '@components/MenuItemList'; -import type {FilterComponentsProps} from '@components/Search/FilterComponents'; +import type {TextInputFilterContentProps} from '@components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent'; +import type {ListFilterContentProps} from '@components/Search/FilterComponents/ListFilterContent'; import type {MultiSelectItem} from '@components/Search/FilterComponents/MultiSelect'; import type {SingleSelectItem} from '@components/Search/FilterComponents/SingleSelect'; import type { @@ -30,6 +31,7 @@ import {GROUP_ITEM_TYPES} from '@components/Search/SearchList/ListItem/types'; import type { GroupedItem, QueryFilters, + ReportFieldKey, ReportFieldTextKey, SearchAmountFilterKeys, SearchColumnType, @@ -186,7 +188,9 @@ import { getFilterFromQuery, isFilterNegatable, isFilterSupported, + isFilterNegated, isSearchDatePreset, + removeNegation, sortOptionsWithEmptyValue, } from './SearchQueryUtils'; import StringUtils from './StringUtils'; @@ -636,7 +640,7 @@ type GetSectionsParams = { */ const GENERIC_SEARCH_KEYS: ReadonlySet = new Set([CONST.SEARCH.SEARCH_KEYS.EXPENSES, CONST.SEARCH.SEARCH_KEYS.REPORTS]); -const SKIPPED_SEARCH_FILTERS = new Set([ +const SKIPPED_SEARCH_FILTERS = new Set([ FILTER_KEYS.GROUP_BY, FILTER_KEYS.GROUP_CURRENCY, FILTER_KEYS.LIMIT, @@ -5044,7 +5048,7 @@ function adjustTimeRangeToDateFilters(timeRange: {start: string; end: string}, d }; } -const FILTER_TO_SYNTAX_KEY: Partial | SearchAmountFilterKeys>> = { +const FILTER_TO_SYNTAX_KEY = { [FILTER_KEYS.DATE_ON]: CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, [FILTER_KEYS.DATE_AFTER]: CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, [FILTER_KEYS.DATE_BEFORE]: CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, @@ -5093,6 +5097,10 @@ const FILTER_TO_SYNTAX_KEY: Partial translate(`common.${option}`)).join(', '); } if ( key === FILTER_KEYS.FROM || + key === FILTER_KEYS.FROM_NOT || key === FILTER_KEYS.TO || + key === FILTER_KEYS.TO_NOT || key === FILTER_KEYS.ATTENDEE || key === FILTER_KEYS.ASSIGNEE || key === FILTER_KEYS.TAX_RATE || @@ -5474,36 +5484,52 @@ function getDisplayValue( return Array.isArray(formValue) ? formValue.join(', ') : formValue; } -function getFilterNegatableValue( - filterKey: K, +function getFilterNegatableValue( + baseFilterKey: K, values: (Partial & Partial>) | undefined, ): { isNegated: boolean; value: SearchAdvancedFiltersForm[K] | undefined; } { - const negatedFilterKey = `${filterKey}${CONST.SEARCH.NOT_MODIFIER}` as const; - if (!isFilterNegatable(filterKey) || !values || !hasKey(values, negatedFilterKey)) { - return {isNegated: false, value: values?.[filterKey]}; + const negatedFilterKey = `${baseFilterKey}${CONST.SEARCH.NOT_MODIFIER}` as const; + if (!isFilterNegatable(baseFilterKey) || !values || !hasKey(values, negatedFilterKey)) { + return {isNegated: false, value: values?.[baseFilterKey]}; } return {isNegated: true, value: values[negatedFilterKey]}; } +function getLabelValue(key: SearchAdvancedFiltersKey, labelKey: TranslationPaths | undefined, translate: LocalizedTranslate) { + if (!labelKey) { + return undefined; + } + + if (isFilterNegatable(key)) { + const prefix = isFilterNegated(key) ? CONST.SEARCH.NOT_PREFIX : ''; + return `${prefix}${translate(labelKey)}`; + } + return translate(labelKey); +} + function shouldShowFilter(skipFilters: Set | undefined, key: SearchAdvancedFiltersKey, value: ValueOf, type: SearchDataTypes) { - return !skipFilters?.has(key) && isFilterSupported(key, type) && value && (!Array.isArray(value) || value.length > 0); + return !skipFilters?.has(key) && (isFilterNegatable(key) || !isFilterNegated(key)) && isFilterSupported(key, type) && value && (!Array.isArray(value) || value.length > 0); } function isTextFilterKey(key: string): key is SearchTextFilterKeys { return (TEXT_FILTER_KEYS as Set).has(key); } -const isAmountFilterKey = (key: SearchFilter['key']): key is SearchAmountFilterKeys => { +function isAmountFilterKey(key: string): key is SearchAmountFilterKeys { return AMOUNT_FILTER_KEYS.includes(key as SearchAmountFilterKeys); -}; +} -const isDateFilterKey = (key: SearchFilter['key']): key is Exclude => { +function isDateFilterKey(key: string): key is Exclude { return DATE_FILTER_KEYS.includes(key as SearchDateFilterKeys); -}; +} + +function isReportFieldKey(key: string): key is ReportFieldKey { + return key.startsWith(CONST.SEARCH.REPORT_FIELD.GLOBAL_PREFIX); +} type SearchFilter = { key: keyof typeof FILTER_VIEW_MAP; @@ -5511,15 +5537,36 @@ type SearchFilter = { value: string | string[]; }; +type MappedFilterKey = SearchFilter['key'] | Extract; + +function isMappedFilterKey(key: string): key is MappedFilterKey { + return hasKey(FILTER_VIEW_MAP, removeNegation(key)); +} + +function mapFiltersFormToLabelValueList( + searchAdvancedFiltersForm: Partial, + skipFilters: Set | undefined, + translate: LocalizedTranslate, + localeCompare: LocaleContextProps['localeCompare'], + convertToDisplayStringWithoutCurrency: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency'], +): SearchFilter[]; function mapFiltersFormToLabelValueList>( searchAdvancedFiltersForm: Partial, skipFilters: Set | undefined, translate: LocalizedTranslate, localeCompare: LocaleContextProps['localeCompare'], convertToDisplayStringWithoutCurrency: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency'], - mapper?: (filterKey: SearchFilter['key']) => T, -): Array { - const filters: Array = []; + mapper: (filterKey: MappedFilterKey) => T, +): Array; +function mapFiltersFormToLabelValueList( + searchAdvancedFiltersForm: Partial, + skipFilters: Set | undefined, + translate: LocalizedTranslate, + localeCompare: LocaleContextProps['localeCompare'], + convertToDisplayStringWithoutCurrency: CurrencyListActionsContextType['convertToDisplayStringWithoutCurrency'], + mapper?: (filterKey: MappedFilterKey) => Record, +): SearchFilter[] { + const filters: SearchFilter[] = []; const addedGroups = new Set(); const type = searchAdvancedFiltersForm.type ?? CONST.SEARCH.DATA_TYPES.EXPENSE; @@ -5530,8 +5577,8 @@ function mapFiltersFormToLabelValueList>( } // Handle grouped filters (date, amount) - only add once per group - const syntax = FILTER_TO_SYNTAX_KEY[key]; - if (syntax) { + if (isGroupedFilterKey(key)) { + const syntax = FILTER_TO_SYNTAX_KEY[key]; if (addedGroups.has(syntax)) { continue; } @@ -5543,8 +5590,7 @@ function mapFiltersFormToLabelValueList>( if (displayValue && label) { addedGroups.add(syntax); - const extra = mapper?.(syntax) ?? ({} as T); - filters.push({key: syntax, label: translate(label), value: displayValue, ...extra}); + filters.push({key: syntax, label: translate(label), value: displayValue, ...mapper?.(syntax)}); } continue; } @@ -5558,20 +5604,24 @@ function mapFiltersFormToLabelValueList>( const value = getReportFieldDisplayValue(searchAdvancedFiltersForm, translate); if (value) { addedGroups.add(CONST.SEARCH.REPORT_FIELD.GLOBAL_PREFIX); - const extra = mapper?.(CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_FIELD) ?? ({} as T); + const extra = mapper?.(CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_FIELD); filters.push({key: CONST.SEARCH.SYNTAX_FILTER_KEYS.REPORT_FIELD, label: translate('workspace.common.reportField'), value, ...extra}); } continue; } // Handle regular filters - const label = key in FILTER_VIEW_MAP ? FILTER_VIEW_MAP[key as keyof typeof FILTER_VIEW_MAP].labelKey : undefined; + if (!isMappedFilterKey(key)) { + continue; + } + + const baseKey = removeNegation(key); + const labelKey = FILTER_VIEW_MAP[baseKey].labelKey; const value = getDisplayValue(key, searchAdvancedFiltersForm, type, translate, localeCompare); + const label = getLabelValue(key, labelKey, translate); if (label && value && !(Array.isArray(value) && value.length === 0)) { - const filterLabelMapKey = key as keyof typeof FILTER_VIEW_MAP; - const extra = mapper?.(filterLabelMapKey) ?? ({} as T); - filters.push({key: filterLabelMapKey, label: translate(label), value, ...extra}); + filters.push({key: baseKey, label, value, ...mapper?.(key)}); } } @@ -6552,6 +6602,7 @@ export { isTextFilterKey, isAmountFilterKey, isDateFilterKey, + isReportFieldKey, getSingleSelectFilterOptions, getMultiSelectFilterOptions, TODO_SEARCH_KEYS, diff --git a/src/pages/Search/SearchAdvancedFiltersContentPage/AmountFilterContentPageWrapper.tsx b/src/pages/Search/SearchAdvancedFiltersContentPage/AmountFilterContentPageWrapper.tsx index ef2bccc125b3..66a429313925 100644 --- a/src/pages/Search/SearchAdvancedFiltersContentPage/AmountFilterContentPageWrapper.tsx +++ b/src/pages/Search/SearchAdvancedFiltersContentPage/AmountFilterContentPageWrapper.tsx @@ -3,10 +3,10 @@ import type {AmountFilterContentWrapperProps} from '@components/Search/FilterCom import React from 'react'; -function AmountFilterContentPageWrapper({filterKey, value, onChange}: AmountFilterContentWrapperProps) { +function AmountFilterContentPageWrapper({baseFilterKey, value, onChange}: AmountFilterContentWrapperProps) { return ( (initialValue); - - return ( - onChange(value)} - /> - } - /> - ); -} - -export default CommonFilterContentPageWrapper; diff --git a/src/pages/Search/SearchAdvancedFiltersContentPage/DateFilterContentPageWrapper.tsx b/src/pages/Search/SearchAdvancedFiltersContentPage/DateFilterContentPageWrapper.tsx index dfd5b04f80d2..02cfbce27892 100644 --- a/src/pages/Search/SearchAdvancedFiltersContentPage/DateFilterContentPageWrapper.tsx +++ b/src/pages/Search/SearchAdvancedFiltersContentPage/DateFilterContentPageWrapper.tsx @@ -10,7 +10,7 @@ import type {SearchDateModifier} from '@libs/SearchUIUtils'; import React, {useState} from 'react'; import {View} from 'react-native'; -function DateFilterContentPageWrapper({filterKey, value: initialValue, hasFeed, onChange}: DateFilterContentWrapperProps) { +function DateFilterContentPageWrapper({baseFilterKey, value: initialValue, hasFeed, onChange}: DateFilterContentWrapperProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const [value, setValue] = useState(initialValue); @@ -19,7 +19,7 @@ function DateFilterContentPageWrapper({filterKey, value: initialValue, hasFeed, return ( onChange(value, isNegated)} + > + {translate('common.confirm')} + + } + /> + ); +} + +export default ListFilterContentPageWrapper; diff --git a/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx b/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx index 755e4eadf384..8a31225995c7 100644 --- a/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx +++ b/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx @@ -22,8 +22,8 @@ import React, {useContext} from 'react'; import {View} from 'react-native'; import AmountFilterContentPageWrapper from './AmountFilterContentPageWrapper'; -import CommonFilterContentPageWrapper from './CommonFilterContentPageWrapper'; import DateFilterContentPageWrapper from './DateFilterContentPageWrapper'; +import ListFilterContentPageWrapper from './ListFilterContentPageWrapper'; import ReportFieldFilterContentPageWrapper from './ReportFieldFilterContentPageWrapper'; import TextInputFilterContentPageWrapper from './TextInputFilterContentPageWrapper'; @@ -64,10 +64,10 @@ function SearchAdvancedFiltersContentBase() { minWidth: CONST.ADVANCED_FILTERS_CONTENT_WIDTH, }, + negatableFilterButtons: { + flexDirection: 'row', + minWidth: 180, + borderRadius: variables.buttonBorderRadius, + }, + searchActionsBarContainer: { marginTop: 12, marginBottom: 16, diff --git a/src/types/form/SearchAdvancedFiltersForm.ts b/src/types/form/SearchAdvancedFiltersForm.ts index c48397e56edf..790f90f0247d 100644 --- a/src/types/form/SearchAdvancedFiltersForm.ts +++ b/src/types/form/SearchAdvancedFiltersForm.ts @@ -805,7 +805,7 @@ type SearchAdvancedFiltersForm = Form< [FILTER_KEYS.ACTION_NOT]: string; [FILTER_KEYS.HAS]: HasFilterValues; - [FILTER_KEYS.HAS_NOT]: string[]; + [FILTER_KEYS.HAS_NOT]: HasFilterValues; [FILTER_KEYS.IS]: IsFilterValues; [FILTER_KEYS.IS_NOT]: string[]; diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index de981e99ada0..7fbe66e87ab0 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -29,6 +29,7 @@ import { getQueryWithUpdatedValues, getRangeBoundariesFromFormValue, getRoutes, + isFilterNegated, isDefaultExpenseReportsQuery, isDefaultExpensesQuery, isSearchRootParams, @@ -3571,6 +3572,23 @@ describe('SearchQueryUtils', () => { }); }); + describe('isNegated', () => { + it('returns true for negated filter keys (ending with the NOT modifier)', () => { + expect(isFilterNegated(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT}${CONST.SEARCH.NOT_MODIFIER}`)).toBe(true); + expect(isFilterNegated(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM}${CONST.SEARCH.NOT_MODIFIER}`)).toBe(true); + expect(isFilterNegated(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.TO}${CONST.SEARCH.NOT_MODIFIER}`)).toBe(true); + expect(isFilterNegated(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.HAS}${CONST.SEARCH.NOT_MODIFIER}`)).toBe(true); + expect(isFilterNegated(`${CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY}${CONST.SEARCH.NOT_MODIFIER}`)).toBe(true); + }); + + it('returns false for base (non-negated) filter keys', () => { + expect(isFilterNegated(CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT)).toBe(false); + expect(isFilterNegated(CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM)).toBe(false); + expect(isFilterNegated(CONST.SEARCH.SYNTAX_FILTER_KEYS.TO)).toBe(false); + expect(isFilterNegated(CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY)).toBe(false); + }); + }); + describe('getFilterFromQuery', () => { test('returns all comma-separated values for a non-negated filter', () => { const queryJSON = buildSearchQueryJSON('type:expense policyID:123,456'); diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 2447829c1282..94b3dbb47c14 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -11116,6 +11116,82 @@ describe('SearchUIUtils', () => { }); }); + describe('getFilterNegatableValue', () => { + const MERCHANT = CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT; + const MERCHANT_NEGATED = `${MERCHANT}${CONST.SEARCH.NOT_MODIFIER}` as const; + const CATEGORY = CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY; + const CATEGORY_NEGATED = `${CATEGORY}${CONST.SEARCH.NOT_MODIFIER}` as const; + + it('returns the negated value with isNegated true when only the negated value is set', () => { + expect(SearchUIUtils.getFilterNegatableValue(MERCHANT, {[MERCHANT_NEGATED]: 'Uber'})).toEqual({isNegated: true, value: 'Uber'}); + }); + + it('returns the base value with isNegated false when only the base value is set', () => { + expect(SearchUIUtils.getFilterNegatableValue(MERCHANT, {[MERCHANT]: 'Uber'})).toEqual({isNegated: false, value: 'Uber'}); + }); + + it('prefers the negated value when both base and negated values are set', () => { + expect(SearchUIUtils.getFilterNegatableValue(MERCHANT, {[MERCHANT]: 'Lyft', [MERCHANT_NEGATED]: 'Uber'})).toEqual({isNegated: true, value: 'Uber'}); + }); + + it('returns isNegated false and undefined value when neither value is set', () => { + expect(SearchUIUtils.getFilterNegatableValue(MERCHANT, {})).toEqual({isNegated: false, value: undefined}); + }); + + it('returns isNegated false and undefined value when the values object is undefined', () => { + expect(SearchUIUtils.getFilterNegatableValue(MERCHANT, undefined)).toEqual({isNegated: false, value: undefined}); + }); + + it('always returns isNegated false and the base value for non-negatable filter', () => { + expect(SearchUIUtils.getFilterNegatableValue(CATEGORY, {[CATEGORY]: ['Food'], [CATEGORY_NEGATED]: ['Travel']})).toEqual({isNegated: false, value: ['Food']}); + }); + }); + + describe('shouldShowFilter', () => { + const MERCHANT = CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT; + const MERCHANT_NEGATED = `${MERCHANT}${CONST.SEARCH.NOT_MODIFIER}` as const; + const CATEGORY = CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY; + const CATEGORY_NEGATED = `${CATEGORY}${CONST.SEARCH.NOT_MODIFIER}` as const; + const EXPENSE = CONST.SEARCH.DATA_TYPES.EXPENSE; + const CHAT = CONST.SEARCH.DATA_TYPES.CHAT; + + it('returns truthy for a supported filter with a non-empty value', () => { + expect(SearchUIUtils.shouldShowFilter(undefined, MERCHANT, 'Uber', EXPENSE)).toBeTruthy(); + }); + + it('returns truthy for a supported negated negatable filter with a value', () => { + expect(SearchUIUtils.shouldShowFilter(undefined, MERCHANT_NEGATED, 'Uber', EXPENSE)).toBeTruthy(); + }); + + it('returns truthy for a supported filter with a non-empty array value', () => { + expect(SearchUIUtils.shouldShowFilter(undefined, CATEGORY, ['Food'], EXPENSE)).toBeTruthy(); + }); + + it('returns falsy when the filter is included in skipFilters', () => { + expect(SearchUIUtils.shouldShowFilter(new Set([MERCHANT]), MERCHANT, 'Uber', EXPENSE)).toBeFalsy(); + }); + + it('returns falsy when the value is undefined', () => { + expect(SearchUIUtils.shouldShowFilter(undefined, MERCHANT, undefined, EXPENSE)).toBeFalsy(); + }); + + it('returns falsy when the value is an empty string', () => { + expect(SearchUIUtils.shouldShowFilter(undefined, MERCHANT, '', EXPENSE)).toBeFalsy(); + }); + + it('returns falsy when the value is an empty array', () => { + expect(SearchUIUtils.shouldShowFilter(undefined, CATEGORY, [], EXPENSE)).toBeFalsy(); + }); + + it('returns falsy for a negated key that is not negatable', () => { + expect(SearchUIUtils.shouldShowFilter(undefined, CATEGORY_NEGATED, ['Food'], EXPENSE)).toBeFalsy(); + }); + + it('returns falsy when the filter is not supported for the data type', () => { + expect(SearchUIUtils.shouldShowFilter(undefined, MERCHANT, 'Uber', CHAT)).toBeFalsy(); + }); + }); + describe('isTextFilterKey', () => { it.each([ CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT,