diff --git a/src/components/Search/FilterComponents/AdvancedFilters/MerchantFilterContent.tsx b/src/components/Search/FilterComponents/AdvancedFilters/MerchantFilterContent.tsx new file mode 100644 index 000000000000..badf05abfa88 --- /dev/null +++ b/src/components/Search/FilterComponents/AdvancedFilters/MerchantFilterContent.tsx @@ -0,0 +1,108 @@ +/** + * Renders the advanced Merchant filter input, match type, and negation controls. + */ +import MerchantMatchTypeSelector from '@components/Search/FilterComponents/MerchantMatchTypeSelector'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; +import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm'; +import type {MerchantMatchType, SearchAdvancedFiltersForm} from '@src/types/form/SearchAdvancedFiltersForm'; + +import type {StyleProp, ViewStyle} from 'react-native'; +import type {ValueOf} from 'type-fest'; + +import React, {useState} from 'react'; +import {View} from 'react-native'; + +import TextInputFilterContent from './TextInputFilterContent'; + +type MerchantFilterContentProps = { + /** The Merchant filter key. */ + baseFilterKey: typeof CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT; + + /** The current Merchant value. */ + value: string | undefined; + + /** Whether the filter currently uses the negated value. */ + isNegated: boolean; + + /** The current Merchant match type. */ + merchantOperator?: MerchantMatchType; + + /** The button size used for confirmation. */ + buttonSize?: Exclude, typeof CONST.BUTTON_SIZE.SMALL>; + + /** The label used when the filter applies immediately. */ + buttonText?: string; + + /** The Sentry label for the confirmation button. */ + sentryLabel?: string; + + /** Whether the input should receive focus automatically. */ + autoFocus?: boolean; + + /** Additional styles for the filter content. */ + style?: StyleProp; + + /** Additional styles for the confirmation button. */ + buttonStyles?: StyleProp; + + /** Called with the updated Merchant filter form values. */ + onChange: (values: Partial) => void; +}; + +function MerchantFilterContent({ + baseFilterKey, + value, + isNegated, + merchantOperator: initialMerchantOperator, + buttonSize, + buttonText, + sentryLabel, + autoFocus, + style, + buttonStyles, + onChange, +}: MerchantFilterContentProps) { + const styles = useThemeStyles(); + const [merchantOperator, setMerchantOperator] = useState(initialMerchantOperator ?? CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS); + + const updateMerchantFilter = (newValue: string | undefined, newIsNegated: boolean) => { + onChange({ + [FILTER_KEYS.MERCHANT]: newIsNegated ? undefined : newValue, + [FILTER_KEYS.MERCHANT_NOT]: newIsNegated ? newValue : undefined, + [FILTER_KEYS.MERCHANT_OPERATOR]: newIsNegated ? undefined : merchantOperator, + }); + }; + + // A negated Merchant filter always uses the not-equal operator, so the match type is hidden. + const renderMatchTypeSelector = (isFilterNegated: boolean) => + isFilterNegated ? null : ( + + + + ); + + return ( + + ); +} + +export default MerchantFilterContent; +export type {MerchantFilterContentProps}; diff --git a/src/components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent.tsx b/src/components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent.tsx index 5abdfd5b75fa..ff0bad60263a 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent.tsx @@ -6,11 +6,13 @@ import type {SearchFilter} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; +import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm'; import React from 'react'; import type {AmountFilterContentProps} from './AmountFilterContent'; import type {DateFilterContentProps} from './DateFilterContent'; +import type {MerchantFilterContentProps} from './MerchantFilterContent'; import type {ReportFieldFilterContentProps} from './ReportFieldFilterContent'; import type {TextInputFilterContentProps} from './TextInputFilterContent'; @@ -18,6 +20,7 @@ type TextInputFilterContentWrapperProps = Pick; type DateFilterContentWrapperProps = Pick & {buttonText?: string}; type ReportFieldFilterContentWrapperProps = Pick & {buttonText?: string}; +type MerchantFilterContentWrapperProps = Pick; type ListFilterContentWrapperProps = Omit & { onChange: (value: ListFilterContentProps['value'], isNegated: boolean) => void; buttonText?: string; @@ -29,6 +32,7 @@ type SearchAdvancedFiltersContentProps = { ready?: boolean; buttonText?: string; components: { + Merchant: React.ComponentType; Text: React.ComponentType; Amount: React.ComponentType; Date: React.ComponentType; @@ -98,6 +102,22 @@ function SearchAdvancedFiltersContent({baseFilterKey, values, ready, components, ); } + if (baseFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT) { + const {isNegated, value} = getFilterNegatableValue(baseFilterKey, values); + const MerchantFilter = components.Merchant; + return ( + + ); + } + if (isTextFilterKey(baseFilterKey)) { const {isNegated, value} = getFilterNegatableValue(baseFilterKey, values); const TextFilter = components.Text; @@ -131,4 +151,11 @@ function SearchAdvancedFiltersContent({baseFilterKey, values, ready, components, } export default SearchAdvancedFiltersContent; -export type {TextInputFilterContentWrapperProps, AmountFilterContentWrapperProps, DateFilterContentWrapperProps, ReportFieldFilterContentWrapperProps, ListFilterContentWrapperProps}; +export type { + TextInputFilterContentWrapperProps, + MerchantFilterContentWrapperProps, + AmountFilterContentWrapperProps, + DateFilterContentWrapperProps, + ReportFieldFilterContentWrapperProps, + ListFilterContentWrapperProps, +}; diff --git a/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx b/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx index 6242f00b45ae..077606301b62 100644 --- a/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx +++ b/src/components/Search/FilterComponents/AdvancedFilters/TextInputFilterContent.tsx @@ -15,7 +15,7 @@ import {FILTER_VIEW_MAP} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; -import type {ComponentRef} from 'react'; +import type {ComponentRef, ReactNode} from 'react'; import type {TextInput as RNTextInput, StyleProp, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -30,6 +30,12 @@ type TextInputFilterContentProps = { autoFocus?: boolean; style?: StyleProp; buttonText?: string; + buttonStyles?: StyleProp; + sentryLabel?: string; + + /** Renders extra controls under the input, inside the scrollable area. Receives the current negation state, so the content can change when the user negates the filter. */ + renderBelowInput?: (isNegated: boolean) => ReactNode; + onChange: (value: string | undefined, isNegated: boolean) => void; }; @@ -37,7 +43,19 @@ function isTextInput(element: BaseTextInputRef | ComponentRef { @@ -59,6 +77,7 @@ function TextInputFilterContent({baseFilterKey, value: initialValue, isNegated: } onChange(value, isNegated); }} + sentryLabel={sentryLabel} > {buttonText ?? translate('common.confirm')} @@ -93,6 +112,7 @@ function TextInputFilterContent({baseFilterKey, value: initialValue, isNegated: containerStyles={[styles.ph5]} /> + {renderBelowInput?.(isNegated)} {shouldButtonBeInScrollView && button} {!shouldButtonBeInScrollView && button} diff --git a/src/components/Search/FilterComponents/MerchantMatchTypeSelector.tsx b/src/components/Search/FilterComponents/MerchantMatchTypeSelector.tsx new file mode 100644 index 000000000000..cbcf743be352 --- /dev/null +++ b/src/components/Search/FilterComponents/MerchantMatchTypeSelector.tsx @@ -0,0 +1,62 @@ +/** + * Shared Merchant match type selector for search filter surfaces. + */ +import SelectionList from '@components/SelectionList'; +import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; +import type {ListItem} from '@components/SelectionList/ListItem/types'; +import Text from '@components/Text'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; +import type {MerchantMatchType} from '@src/types/form/SearchAdvancedFiltersForm'; + +import React from 'react'; +import {View} from 'react-native'; + +type MerchantMatchTypeSelectorProps = { + /** Currently selected Merchant match type. */ + value: MerchantMatchType; + + /** Updates the selected Merchant match type. */ + onChange: (value: MerchantMatchType) => void; +}; + +function MerchantMatchTypeSelector({value, onChange}: MerchantMatchTypeSelectorProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const items: Array> = [ + { + keyForList: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, + text: translate('search.filters.merchant.contains'), + isSelected: value === CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, + }, + { + keyForList: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + text: translate('search.filters.merchant.equalTo'), + isSelected: value === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + }, + ]; + + return ( + <> + + {translate('search.filters.merchant.matchType')} + + {/* Inside a ScrollView a flex list can shrink to zero height when the keyboard is open, so reserve room for every row */} + + onChange(item.keyForList)} + shouldSingleExecuteRowSelect + /> + + + ); +} + +export default MerchantMatchTypeSelector; diff --git a/src/components/Search/FilterDropdowns/MerchantFilterPopup.tsx b/src/components/Search/FilterDropdowns/MerchantFilterPopup.tsx new file mode 100644 index 000000000000..33b6faed72d1 --- /dev/null +++ b/src/components/Search/FilterDropdowns/MerchantFilterPopup.tsx @@ -0,0 +1,68 @@ +/** + * Edits the Merchant filter value and match type from its search filter chip. + */ +import MerchantFilterContent from '@components/Search/FilterComponents/AdvancedFilters/MerchantFilterContent'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getFilterNegatableValue} from '@libs/SearchUIUtils'; + +import type CONST from '@src/CONST'; +import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm'; +import type {SearchAdvancedFiltersForm} from '@src/types/form/SearchAdvancedFiltersForm'; + +import React from 'react'; + +import type {PopoverComponentProps} from './FilterPopupButton'; + +import BasePopup from './BasePopup'; + +type MerchantFilterPopupProps = { + /** The Merchant filter key. */ + baseFilterKey: typeof CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT; + /** The current search filter form values. */ + values: Partial | undefined; + /** The translated filter label. */ + label: string; + /** Closes the filter popup. */ + closeOverlay: PopoverComponentProps['closeOverlay']; + /** Applies the updated Merchant filter values. */ + updateFilterForm: (value: Partial) => void; +}; + +function MerchantFilterPopup({baseFilterKey, values, label, updateFilterForm, closeOverlay}: MerchantFilterPopupProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {isNegated, value} = getFilterNegatableValue(baseFilterKey, values); + const applySentryLabel = `Search-FilterPopupApply-${baseFilterKey}`; + + const applyChanges = (newValues: Partial) => { + updateFilterForm(newValues); + closeOverlay(); + }; + + // The content renders its own padded Apply button, so the popup only provides the label and the spacing ActionButtons would add. + return ( + + + + ); +} + +export default MerchantFilterPopup; diff --git a/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/MerchantFilterContentPopupWrapper.tsx b/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/MerchantFilterContentPopupWrapper.tsx new file mode 100644 index 000000000000..cbb0c74e9c1c --- /dev/null +++ b/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/MerchantFilterContentPopupWrapper.tsx @@ -0,0 +1,24 @@ +import MerchantFilterContent from '@components/Search/FilterComponents/AdvancedFilters/MerchantFilterContent'; +import type {MerchantFilterContentWrapperProps} from '@components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +function MerchantFilterContentPopupWrapper({baseFilterKey, value, isNegated, merchantOperator, buttonText, onChange}: MerchantFilterContentWrapperProps) { + const styles = useThemeStyles(); + + return ( + + ); +} + +export default MerchantFilterContentPopupWrapper; diff --git a/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx b/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx index d44cb288afb1..f88d5c2ae818 100644 --- a/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx +++ b/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx @@ -26,6 +26,7 @@ import {View} from 'react-native'; import AmountFilterContentPopupWrapper from './AmountFilterContentPopupWrapper'; import DateFilterContentPopupWrapper from './DateFilterContentPopupWrapper'; import ListFilterContentPopupWrapper from './ListFilterContentPopupWrapper'; +import MerchantFilterContentPopupWrapper from './MerchantFilterContentPopupWrapper'; import ReportFieldFilterContentPopupWrapper from './ReportFieldFilterContentPopupWrapper'; import TextInputFilterContentPopupWrapper from './TextInputFilterContentPopupWrapper'; @@ -56,6 +57,7 @@ type MountedFilterState = { const filterComponents = { List: ListFilterContentPopupWrapper, + Merchant: MerchantFilterContentPopupWrapper, Text: TextInputFilterContentPopupWrapper, Amount: AmountFilterContentPopupWrapper, Date: DateFilterContentPopupWrapper, diff --git a/src/components/Search/FilterDropdowns/TextFilterPopup.tsx b/src/components/Search/FilterDropdowns/TextFilterPopup.tsx index e0be680d1f80..11200fec643f 100644 --- a/src/components/Search/FilterDropdowns/TextFilterPopup.tsx +++ b/src/components/Search/FilterDropdowns/TextFilterPopup.tsx @@ -18,7 +18,7 @@ import type {PopoverComponentProps} from './FilterPopupButton'; import BasePopup from './BasePopup'; type TextFilterPopupProps = { - baseFilterKey: Exclude; + baseFilterKey: Exclude; values: Partial | undefined; label: string; closeOverlay: PopoverComponentProps['closeOverlay']; diff --git a/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx b/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx index 4d35f300083c..4934194ea162 100644 --- a/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx +++ b/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx @@ -2,6 +2,7 @@ import {ListFilterHeightContextProvider} from '@components/Search/FilterComponen import AmountPopup from '@components/Search/FilterDropdowns/AmountPopup'; import type {PopoverComponentProps} from '@components/Search/FilterDropdowns/FilterPopupButton'; import ListPopup from '@components/Search/FilterDropdowns/ListPopup'; +import MerchantFilterPopup from '@components/Search/FilterDropdowns/MerchantFilterPopup'; import ReportFieldPopup from '@components/Search/FilterDropdowns/ReportFieldPopup'; import TextFilterPopup from '@components/Search/FilterDropdowns/TextFilterPopup'; import useUpdateFilterQuery from '@components/Search/hooks/useUpdateFilterQuery'; @@ -119,6 +120,18 @@ function FilterPopup({baseFilterKey, isDefault, searchAdvancedFiltersForm, close ); } + if (baseFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT) { + return ( + + ); + } + if (isTextFilterKey(baseFilterKey)) { return ( = { [CONST.SEARCH.GROUP_BY.MERCHANT]: { titleIconName: 'Basket', getLabel: (item: GroupedItem) => (item as TransactionMerchantGroupListItemType).formattedMerchant ?? '', - getFilterQuery: (item: GroupedItem) => `merchant:"${(item as TransactionMerchantGroupListItemType).merchant}"`, + getFilterQuery: (item: GroupedItem) => `merchant="${(item as TransactionMerchantGroupListItemType).merchant}"`, }, [CONST.SEARCH.GROUP_BY.TAG]: { titleIconName: 'Tag', diff --git a/src/components/Search/hooks/useUpdateFilterQuery.tsx b/src/components/Search/hooks/useUpdateFilterQuery.tsx index 5752151e3967..810496cb6230 100644 --- a/src/components/Search/hooks/useUpdateFilterQuery.tsx +++ b/src/components/Search/hooks/useUpdateFilterQuery.tsx @@ -41,13 +41,7 @@ function useUpdateFilterQuery(queryJSON: SearchQueryJSON | undefined) { } function setFilterQueryParams(values: Partial) { - const queryString = - buildFilterQueryWithSortDefaults( - values, - {view: searchAdvancedFiltersForm.view, groupBy: searchAdvancedFiltersForm.groupBy}, - {sortBy: queryJSON?.sortBy, sortOrder: queryJSON?.sortOrder}, - policies, - ) ?? ''; + const queryString = buildFilterQueryWithSortDefaults(values, {view: searchAdvancedFiltersForm.view, groupBy: searchAdvancedFiltersForm.groupBy}, queryJSON ?? {}, policies) ?? ''; if (!queryString) { return; } diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 365c144d0fec..3c4aba5c0cf2 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -431,8 +431,6 @@ type SearchQueryJSON = { similarSearchHash: number; flatFilters: QueryFilters; isViewExplicitlySet?: boolean; - /** Filters generated for a specific group that must keep exact-match semantics when sent to the backend. */ - exactMatchFilterKeys?: SearchFilterKey[]; } & SearchQueryAST; type SearchAutocompleteResult = { diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index eda29b41fdba..54c4528b95a6 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -7,17 +7,7 @@ import type {PopoverMenuItem} from '@components/PopoverMenu'; import {useOpenSearchReportSubmitToPopover} from '@components/ReportSubmitToPopoverAnchor'; import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; import {getSearchGroupCountByKey} from '@components/Search/selectionBuilders'; -import type { - BulkPaySelectionData, - PaymentData, - QueryFilterKey, - SearchColumnType, - SearchFilterKey, - SearchGroupBy, - SearchQueryJSON, - SelectedReports, - SelectedTransactions, -} from '@components/Search/types'; +import type {BulkPaySelectionData, PaymentData, QueryFilterKey, SearchColumnType, SearchGroupBy, SearchQueryJSON, SelectedReports, SelectedTransactions} from '@components/Search/types'; import {getAccountingIntegrationDisplayName, getExportLabelForConnection, isIntuitEnterpriseSuiteConnection} from '@libs/AccountingUtils'; import {getExpensifyCardStatementPDF} from '@libs/actions/CompanyCards'; @@ -462,8 +452,6 @@ function getAllMatchingReportQuery(queryJSON: SearchQueryJSON, excludedTransacti return buildSearchQueryJSON(buildSearchQueryString({...queryJSON, flatFilters})); } -const MERCHANT_GROUP_EXACT_MATCH_FILTER_KEYS = new Set([CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]); - /** * The groupings whose selected groups `getSelectedGroupFilterEntry` can turn into a query filter, which a template export needs. * Keep this in sync with that function. @@ -477,10 +465,6 @@ const TEMPLATE_EXPORTABLE_GROUP_BYS = new Set([ CONST.SEARCH.GROUP_BY.TAG, ]); -function getGroupExportExactMatchFilterKeys(groupBy: SearchQueryJSON['groupBy']): ReadonlySet | undefined { - return groupBy === CONST.SEARCH.GROUP_BY.MERCHANT ? MERCHANT_GROUP_EXACT_MATCH_FILTER_KEYS : undefined; -} - type ShouldShowBulkDuplicateParams = { selectedTransactionsKeys: string[]; selectedTransactions: Record; @@ -1101,9 +1085,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { templateType, // searchKey changes what the backend query matches (e.g. reconciliation includes Expensify Card cash back), // so the export must send it exactly as search() does or the exported set differs from the viewed set. - jsonQuery: groupExportQueryJSON - ? serializeQueryJSONForBackend({...groupExportQueryJSON, searchKey: currentSearchKey}, getGroupExportExactMatchFilterKeys(queryJSON?.groupBy)) - : '{}', + jsonQuery: groupExportQueryJSON ? serializeQueryJSONForBackend({...groupExportQueryJSON, searchKey: currentSearchKey}) : '{}', reportIDList: isGroupExport ? [] : selectedTransactionReportIDs, transactionIDList, policyID, @@ -1149,7 +1131,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const exportSearchType = searchResults?.search.type ?? queryJSON?.type; const getCSVExportParameters = useCallback( - (isBasicExport: boolean, queryJSONToExport: SearchQueryJSON | undefined, exactMatchFilterKeys?: ReadonlySet) => { + (isBasicExport: boolean, queryJSONToExport: SearchQueryJSON | undefined) => { const groupBy = getValidGroupBy(queryJSON?.groupBy); let columnsToExport: SearchColumnType[]; let groupColumnsToExport: SearchColumnType[] = []; @@ -1209,7 +1191,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { searchKey: currentSearchKey, } : undefined; - const jsonQuery = queryToExport ? serializeQueryJSONForBackend(queryToExport, exactMatchFilterKeys) : (JSON.stringify(queryJSONToExport) ?? ''); + const jsonQuery = queryToExport ? serializeQueryJSONForBackend(queryToExport) : (JSON.stringify(queryJSONToExport) ?? ''); return { jsonQuery, @@ -1267,8 +1249,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { let didFail = false; const reportIDList = selectedReports.length > 0 ? selectedReportIDs : selectedTransactionReportIDs; const queryJSONToExport = isGroupExport && queryJSON ? addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data) : queryJSON; - const exactMatchFilterKeys = isGroupExport ? getGroupExportExactMatchFilterKeys(queryJSON?.groupBy) : undefined; - const exportParameters = getCSVExportParameters(isBasicExport, queryJSONToExport, exactMatchFilterKeys); + const exportParameters = getCSVExportParameters(isBasicExport, queryJSONToExport); await exportSearchItemsToCSV( { jsonQuery: exportParameters.jsonQuery, diff --git a/src/languages/de.ts b/src/languages/de.ts index 9443b7ef5276..9827ec3846cf 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -10216,6 +10216,11 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Export', }, filterType: {label: 'Filtertyp', has: {positive: 'hat', negative: 'hat nicht'}, is: {positive: 'ist', negative: 'ist nicht'}}, + merchant: { + matchType: 'Übereinstimmungstyp', + contains: (merchant?: string) => (merchant ? `Enthält "${merchant}"` : 'Enthält'), + equalTo: (merchant?: string) => (merchant ? `Gleich "${merchant}"` : 'Gleich'), + }, created: 'Erstellt', createdDate: 'Erstellungsdatum', transactionStatus: { diff --git a/src/languages/el.ts b/src/languages/el.ts index 45c848e2985e..8cded4ab39b9 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -10422,6 +10422,11 @@ ${reportName}`, has: {positive: 'έχει', negative: 'δεν έχει'}, is: {positive: 'είναι', negative: 'δεν είναι'}, }, + merchant: { + matchType: 'Τύπος αντιστοίχισης', + contains: (merchant?: string) => (merchant ? `Περιέχει "${merchant}"` : 'Περιέχει'), + equalTo: (merchant?: string) => (merchant ? `Ίσο με "${merchant}"` : 'Ίσο με'), + }, created: 'Δημιουργήθηκε', createdDate: 'Ημερομηνία δημιουργίας', transactionStatus: { diff --git a/src/languages/en.ts b/src/languages/en.ts index 082a4aed7b8d..c62f5c6a6a17 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -10390,6 +10390,11 @@ const translations = { }, equalTo: (amount?: string) => `Equal to ${amount ?? ''}`, }, + merchant: { + matchType: 'Match type', + contains: (merchant?: string) => (merchant ? `Contains "${merchant}"` : 'Contains'), + equalTo: (merchant?: string) => (merchant ? `Equal to "${merchant}"` : 'Equal to'), + }, card: { expensify: 'Expensify', travelInvoicing: 'Consolidated Travel Billing', diff --git a/src/languages/es.ts b/src/languages/es.ts index 5a4f6962d2ca..cac30f42fd37 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -10062,6 +10062,11 @@ ${reportName}`, }, reportField: (name, value) => `${name} es ${value}`, filterType: {label: 'Tipo de filtro', has: {positive: 'tiene', negative: 'no tiene'}, is: {positive: 'es', negative: 'no es'}}, + merchant: { + matchType: 'Tipo de coincidencia', + contains: (merchant) => (merchant ? `Contiene "${merchant}"` : 'Contiene'), + equalTo: (merchant) => (merchant ? `Igual a "${merchant}"` : 'Igual a'), + }, created: 'Creado', createdDate: 'Fecha de creación', transactionStatus: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 1397788a7159..35db94dcd706 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -10241,6 +10241,11 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e [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'}}, + merchant: { + matchType: 'Type de correspondance', + contains: (merchant?: string) => (merchant ? `Contient "${merchant}"` : 'Contient'), + equalTo: (merchant?: string) => (merchant ? `Égal à "${merchant}"` : 'Égal à'), + }, created: 'Créé', createdDate: 'Date de création', transactionStatus: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 9b5a9461ab45..3fcfad69ccc7 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -10168,6 +10168,11 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Esporta', }, filterType: {label: 'Tipo di filtro', has: {positive: 'ha', negative: 'non ha'}, is: {positive: 'è', negative: 'non è'}}, + merchant: { + matchType: 'Tipo di corrispondenza', + contains: (merchant?: string) => (merchant ? `Contiene "${merchant}"` : 'Contiene'), + equalTo: (merchant?: string) => (merchant ? `Uguale a "${merchant}"` : 'Uguale a'), + }, created: 'Creato', createdDate: 'Data di creazione', transactionStatus: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index d4b6395a03d0..40f9025fed71 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -10025,6 +10025,11 @@ ${reportName}`, [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'エクスポート', }, filterType: {label: 'フィルタータイプ', has: {positive: '持っています', negative: '持っていません'}, is: {positive: 'は', negative: 'ではありません'}}, + merchant: { + matchType: 'マッチタイプ', + contains: (merchant?: string) => (merchant ? `"${merchant}"を含む` : '含む'), + equalTo: (merchant?: string) => (merchant ? `"${merchant}"に等しい` : '等しい'), + }, created: '作成しました', createdDate: '作成日', transactionStatus: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 4fd0023754ec..86b7d58480d4 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -10134,6 +10134,11 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Exporteren', }, filterType: {label: 'Filtertype', has: {positive: 'heeft', negative: 'heeft niet'}, is: {positive: 'is', negative: 'is niet'}}, + merchant: { + matchType: 'Overeenkomsttype', + contains: (merchant?: string) => (merchant ? `Bevat "${merchant}"` : 'Bevat'), + equalTo: (merchant?: string) => (merchant ? `Gelijk aan "${merchant}"` : 'Gelijk aan'), + }, created: 'Aangemaakt', createdDate: 'Aanmaakdatum', transactionStatus: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 829cfa260527..c50e6ca08373 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -10151,6 +10151,11 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Eksportuj', }, filterType: {label: 'Typ filtra', has: {positive: 'ma', negative: 'nie ma'}, is: {positive: 'jest', negative: 'nie jest'}}, + merchant: { + matchType: 'Typ dopasowania', + contains: (merchant?: string) => (merchant ? `Zawiera "${merchant}"` : 'Zawiera'), + equalTo: (merchant?: string) => (merchant ? `Równe "${merchant}"` : 'Równe'), + }, created: 'Utworzono', createdDate: 'Data utworzenia', transactionStatus: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index c48f53b822ab..fe9f53b515de 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -10132,6 +10132,11 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, [CONST.SEARCH.ACTION_FILTERS.EXPORT]: 'Exportar', }, filterType: {label: 'Tipo de filtro', has: {positive: 'tem', negative: 'não tem'}, is: {positive: 'é', negative: 'não é'}}, + merchant: { + matchType: 'Tipo de correspondência', + contains: (merchant?: string) => (merchant ? `Contém "${merchant}"` : 'Contém'), + equalTo: (merchant?: string) => (merchant ? `Igual a "${merchant}"` : 'Igual a'), + }, created: 'Criado', createdDate: 'Data de criação', transactionStatus: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 80c14fa5170f..bfec42aa27f3 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -9754,6 +9754,11 @@ ${reportName}`, [CONST.SEARCH.ACTION_FILTERS.EXPORT]: '导出', }, filterType: {label: '筛选类型', has: {positive: '有', negative: '没有'}, is: {positive: '是', negative: '不是'}}, + merchant: { + matchType: '匹配类型', + contains: (merchant?: string) => (merchant ? `包含 "${merchant}"` : '包含'), + equalTo: (merchant?: string) => (merchant ? `等于 "${merchant}"` : '等于'), + }, created: '已创建', createdDate: '创建日期', transactionStatus: { diff --git a/src/libs/SearchParser/autocompleteParser.js b/src/libs/SearchParser/autocompleteParser.js index a3b269757198..d73871042833 100644 --- a/src/libs/SearchParser/autocompleteParser.js +++ b/src/libs/SearchParser/autocompleteParser.js @@ -301,21 +301,22 @@ function peg$parse(input, options) { var peg$c115 = "group-week"; var peg$c116 = "group-year"; var peg$c117 = "group-quarter"; - var peg$c118 = "!="; - var peg$c119 = ">="; - var peg$c120 = ">"; - var peg$c121 = "<="; - var peg$c122 = "<"; - var peg$c123 = "\\"; - var peg$c124 = "\u201C"; - var peg$c125 = "\u201D"; - var peg$c126 = "\""; - var peg$c127 = "footercount"; - var peg$c128 = "footer-count"; - var peg$c129 = "footercurrency"; - var peg$c130 = "footer-currency"; - var peg$c131 = "footertotal"; - var peg$c132 = "footer-total"; + var peg$c118 = "*:"; + var peg$c119 = "!="; + var peg$c120 = ">="; + var peg$c121 = ">"; + var peg$c122 = "<="; + var peg$c123 = "<"; + var peg$c124 = "\\"; + var peg$c125 = "\u201C"; + var peg$c126 = "\u201D"; + var peg$c127 = "\""; + var peg$c128 = "footercount"; + var peg$c129 = "footer-count"; + var peg$c130 = "footercurrency"; + var peg$c131 = "footer-currency"; + var peg$c132 = "footertotal"; + var peg$c133 = "footer-total"; var peg$r0 = /^[ \t\r\n\xA0,:=<>!]/; var peg$r1 = /^[:=]/; @@ -455,36 +456,37 @@ function peg$parse(input, options) { var peg$e120 = peg$literalExpectation("group-year", true); var peg$e121 = peg$literalExpectation("group-quarter", true); var peg$e122 = peg$otherExpectation("operator"); - var peg$e123 = peg$literalExpectation("!=", false); - var peg$e124 = peg$literalExpectation(">=", false); - var peg$e125 = peg$literalExpectation(">", false); - var peg$e126 = peg$literalExpectation("<=", false); - var peg$e127 = peg$literalExpectation("<", false); - var peg$e128 = peg$literalExpectation("\\", false); - var peg$e129 = peg$classExpectation([",", "\"", "\u201D", "\u201C", "\\"], false, false); - var peg$e130 = peg$otherExpectation("word"); - var peg$e131 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e132 = peg$otherExpectation("whitespace"); - var peg$e133 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false); - var peg$e134 = peg$otherExpectation("quote"); - var peg$e135 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e136 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); - var peg$e137 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); - var peg$e138 = peg$literalExpectation("\u201C", false); - var peg$e139 = peg$literalExpectation("\u201D", false); - var peg$e140 = peg$literalExpectation("\"", false); - var peg$e141 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e142 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e143 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false); - var peg$e144 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); - var peg$e145 = peg$classExpectation([","], false, false); - var peg$e146 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); - var peg$e147 = peg$literalExpectation("footerCount", true); - var peg$e148 = peg$literalExpectation("footer-count", true); - var peg$e149 = peg$literalExpectation("footerCurrency", true); - var peg$e150 = peg$literalExpectation("footer-currency", true); - var peg$e151 = peg$literalExpectation("footerTotal", true); - var peg$e152 = peg$literalExpectation("footer-total", true); + var peg$e123 = peg$literalExpectation("*:", false); + var peg$e124 = peg$literalExpectation("!=", false); + var peg$e125 = peg$literalExpectation(">=", false); + var peg$e126 = peg$literalExpectation(">", false); + var peg$e127 = peg$literalExpectation("<=", false); + var peg$e128 = peg$literalExpectation("<", false); + var peg$e129 = peg$literalExpectation("\\", false); + var peg$e130 = peg$classExpectation([",", "\"", "\u201D", "\u201C", "\\"], false, false); + var peg$e131 = peg$otherExpectation("word"); + var peg$e132 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false); + var peg$e133 = peg$otherExpectation("whitespace"); + var peg$e134 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false); + var peg$e135 = peg$otherExpectation("quote"); + var peg$e136 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false); + var peg$e137 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); + var peg$e138 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); + var peg$e139 = peg$literalExpectation("\u201C", false); + var peg$e140 = peg$literalExpectation("\u201D", false); + var peg$e141 = peg$literalExpectation("\"", false); + var peg$e142 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false); + var peg$e143 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false); + var peg$e144 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false); + var peg$e145 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); + var peg$e146 = peg$classExpectation([","], false, false); + var peg$e147 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); + var peg$e148 = peg$literalExpectation("footerCount", true); + var peg$e149 = peg$literalExpectation("footer-count", true); + var peg$e150 = peg$literalExpectation("footerCurrency", true); + var peg$e151 = peg$literalExpectation("footer-currency", true); + var peg$e152 = peg$literalExpectation("footerTotal", true); + var peg$e153 = peg$literalExpectation("footer-total", true); var peg$f0 = function(ranges) { return { autocomplete, ranges }; }; var peg$f1 = function(filters) { return filters.filter(Boolean).flat(); }; @@ -493,6 +495,12 @@ function peg$parse(input, options) { const isNegated = !!neg && !nonNegatableKeys.has(key); + // The "contains" operator is only supported for the merchant field + if (op === "contains" && (neg || key !== "merchant")) { + autocomplete = null; + return; + } + if (!value) { autocomplete = { key, @@ -649,38 +657,39 @@ function peg$parse(input, options) { var peg$f102 = function() { return "groupweek"; }; var peg$f103 = function() { return "groupyear"; }; var peg$f104 = function() { return "groupquarter"; }; - var peg$f105 = function() { return "eq"; }; - var peg$f106 = function() { return "neq"; }; - var peg$f107 = function() { return "gte"; }; - var peg$f108 = function() { return "gt"; }; - var peg$f109 = function() { return "lte"; }; - var peg$f110 = function() { return "lt"; }; - var peg$f111 = function(o) { + var peg$f105 = function() { return "contains"; }; + var peg$f106 = function() { return "eq"; }; + var peg$f107 = function() { return "neq"; }; + var peg$f108 = function() { return "gte"; }; + var peg$f109 = function() { return "gt"; }; + var peg$f110 = function() { return "lte"; }; + var peg$f111 = function() { return "lt"; }; + var peg$f112 = function(o) { if (nameOperator) { - expectingNestedQuote = (o === "eq"); // Use simple parser if no valid operator is found + expectingNestedQuote = (o === "eq" || o === "contains"); } isColumnsContext = false; return o; }; - var peg$f112 = function(c) { return c; }; - var peg$f113 = function(chars) { return chars.join("").trim(); }; - var peg$f114 = function() { + var peg$f113 = function(c) { return c; }; + var peg$f114 = function(chars) { return chars.join("").trim(); }; + var peg$f115 = function() { isColumnsContext = false; return "and"; }; - var peg$f115 = function() { return expectingNestedQuote; }; - var peg$f116 = function(start, inner, end) { //handle no-breaking space + var peg$f116 = function() { return expectingNestedQuote; }; + var peg$f117 = function(start, inner, end) { //handle no-breaking space return [...start, '"', ...inner, '"', ...end].join(""); }; - var peg$f117 = function(start) {return "“"}; - var peg$f118 = function(start) {return "”"}; - var peg$f119 = function(start) {return "\""}; - var peg$f120 = function(start, inner, end) { + var peg$f118 = function(start) {return "“"}; + var peg$f119 = function(start) {return "”"}; + var peg$f120 = function(start) {return "\""}; + var peg$f121 = function(start, inner, end) { return [...start, '"', ...inner, '"'].join(""); }; - var peg$f121 = function() { return "footerCount"; }; - var peg$f122 = function() { return "footerCurrency"; }; - var peg$f123 = function() { return "footerTotal"; }; + var peg$f122 = function() { return "footerCount"; }; + var peg$f123 = function() { return "footerCurrency"; }; + var peg$f124 = function() { return "footerTotal"; }; var peg$currPos = options.peg$currPos | 0; var peg$savedPos = peg$currPos; var peg$posDetailsCache = [{ line: 1, column: 1 }]; @@ -4301,12 +4310,12 @@ function peg$parse(input, options) { peg$silentFails++; s0 = peg$currPos; - s1 = input.charAt(peg$currPos); - if (peg$r1.test(s1)) { - peg$currPos++; + if (input.substr(peg$currPos, 2) === peg$c118) { + s1 = peg$c118; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e99); } + if (peg$silentFails === 0) { peg$fail(peg$e123); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; @@ -4315,12 +4324,12 @@ function peg$parse(input, options) { s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c118) { - s1 = peg$c118; - peg$currPos += 2; + s1 = input.charAt(peg$currPos); + if (peg$r1.test(s1)) { + peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e123); } + if (peg$silentFails === 0) { peg$fail(peg$e99); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; @@ -4343,9 +4352,9 @@ function peg$parse(input, options) { s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 62) { + if (input.substr(peg$currPos, 2) === peg$c120) { s1 = peg$c120; - peg$currPos++; + peg$currPos += 2; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e125); } @@ -4357,9 +4366,9 @@ function peg$parse(input, options) { s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c121) { + if (input.charCodeAt(peg$currPos) === 62) { s1 = peg$c121; - peg$currPos += 2; + peg$currPos++; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e126); } @@ -4371,9 +4380,9 @@ function peg$parse(input, options) { s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 60) { + if (input.substr(peg$currPos, 2) === peg$c122) { s1 = peg$c122; - peg$currPos++; + peg$currPos += 2; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e127); } @@ -4383,6 +4392,21 @@ function peg$parse(input, options) { s1 = peg$f110(); } s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 60) { + s1 = peg$c123; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e128); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f111(); + } + s0 = s1; + } } } } @@ -4404,7 +4428,7 @@ function peg$parse(input, options) { s1 = peg$parseoperator(); if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f111(s1); + s1 = peg$f112(s1); } s0 = s1; @@ -4416,11 +4440,11 @@ function peg$parse(input, options) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 92) { - s1 = peg$c123; + s1 = peg$c124; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e128); } + if (peg$silentFails === 0) { peg$fail(peg$e129); } } if (s1 !== peg$FAILED) { s2 = input.charAt(peg$currPos); @@ -4428,11 +4452,11 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e129); } + if (peg$silentFails === 0) { peg$fail(peg$e130); } } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f112(s2); + s0 = peg$f113(s2); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4458,7 +4482,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e131); } + if (peg$silentFails === 0) { peg$fail(peg$e132); } } } if (s2 !== peg$FAILED) { @@ -4471,7 +4495,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e131); } + if (peg$silentFails === 0) { peg$fail(peg$e132); } } } } @@ -4480,13 +4504,13 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f113(s1); + s1 = peg$f114(s1); } s0 = s1; peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e130); } + if (peg$silentFails === 0) { peg$fail(peg$e131); } } return s0; @@ -4498,7 +4522,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = peg$parse_(); peg$savedPos = s0; - s1 = peg$f114(); + s1 = peg$f115(); s0 = s1; return s0; @@ -4514,7 +4538,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e133); } + if (peg$silentFails === 0) { peg$fail(peg$e134); } } while (s1 !== peg$FAILED) { s0.push(s1); @@ -4523,12 +4547,12 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e133); } + if (peg$silentFails === 0) { peg$fail(peg$e134); } } } peg$silentFails--; s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e132); } + if (peg$silentFails === 0) { peg$fail(peg$e133); } return s0; } @@ -4538,7 +4562,7 @@ function peg$parse(input, options) { s0 = peg$currPos; peg$savedPos = peg$currPos; - s1 = peg$f115(); + s1 = peg$f116(); if (s1) { s1 = undefined; } else { @@ -4575,7 +4599,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e135); } + if (peg$silentFails === 0) { peg$fail(peg$e136); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4584,7 +4608,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e135); } + if (peg$silentFails === 0) { peg$fail(peg$e136); } } } s2 = input.charAt(peg$currPos); @@ -4592,7 +4616,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e136); } + if (peg$silentFails === 0) { peg$fail(peg$e137); } } if (s2 !== peg$FAILED) { s3 = []; @@ -4603,7 +4627,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e137); } + if (peg$silentFails === 0) { peg$fail(peg$e138); } } } while (s4 !== peg$FAILED) { @@ -4615,7 +4639,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e137); } + if (peg$silentFails === 0) { peg$fail(peg$e138); } } } } @@ -4624,7 +4648,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e136); } + if (peg$silentFails === 0) { peg$fail(peg$e137); } } if (s4 !== peg$FAILED) { s5 = []; @@ -4633,7 +4657,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e131); } + if (peg$silentFails === 0) { peg$fail(peg$e132); } } while (s6 !== peg$FAILED) { s5.push(s6); @@ -4642,11 +4666,11 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e131); } + if (peg$silentFails === 0) { peg$fail(peg$e132); } } } peg$savedPos = s0; - s0 = peg$f116(s1, s3, s5); + s0 = peg$f117(s1, s3, s5); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4658,7 +4682,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e134); } + if (peg$silentFails === 0) { peg$fail(peg$e135); } } return s0; @@ -4675,7 +4699,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e135); } + if (peg$silentFails === 0) { peg$fail(peg$e136); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4684,7 +4708,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e135); } + if (peg$silentFails === 0) { peg$fail(peg$e136); } } } s2 = input.charAt(peg$currPos); @@ -4692,7 +4716,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e136); } + if (peg$silentFails === 0) { peg$fail(peg$e137); } } if (s2 !== peg$FAILED) { s3 = []; @@ -4703,7 +4727,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e137); } + if (peg$silentFails === 0) { peg$fail(peg$e138); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -4719,15 +4743,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c124; + s6 = peg$c125; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e138); } + if (peg$silentFails === 0) { peg$fail(peg$e139); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f117(s1); + s4 = peg$f118(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4750,15 +4774,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c125; + s6 = peg$c126; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e139); } + if (peg$silentFails === 0) { peg$fail(peg$e140); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f118(s1); + s4 = peg$f119(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4781,15 +4805,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c126; + s6 = peg$c127; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e140); } + if (peg$silentFails === 0) { peg$fail(peg$e141); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f119(s1); + s4 = peg$f120(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4811,7 +4835,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e137); } + if (peg$silentFails === 0) { peg$fail(peg$e138); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -4827,15 +4851,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c124; + s6 = peg$c125; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e138); } + if (peg$silentFails === 0) { peg$fail(peg$e139); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f117(s1); + s4 = peg$f118(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4858,15 +4882,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c125; + s6 = peg$c126; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e139); } + if (peg$silentFails === 0) { peg$fail(peg$e140); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f118(s1); + s4 = peg$f119(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4889,15 +4913,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c126; + s6 = peg$c127; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e140); } + if (peg$silentFails === 0) { peg$fail(peg$e141); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f119(s1); + s4 = peg$f120(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4914,7 +4938,7 @@ function peg$parse(input, options) { s4 = peg$parseclosingQuote(); if (s4 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f120(s1, s3, s4); + s0 = peg$f121(s1, s3, s4); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4926,7 +4950,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e134); } + if (peg$silentFails === 0) { peg$fail(peg$e135); } } return s0; @@ -4941,7 +4965,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e136); } + if (peg$silentFails === 0) { peg$fail(peg$e137); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -4979,7 +5003,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e141); } + if (peg$silentFails === 0) { peg$fail(peg$e142); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4988,7 +5012,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e141); } + if (peg$silentFails === 0) { peg$fail(peg$e142); } } } s2 = []; @@ -4997,7 +5021,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e142); } + if (peg$silentFails === 0) { peg$fail(peg$e143); } } while (s3 !== peg$FAILED) { s2.push(s3); @@ -5006,7 +5030,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e142); } + if (peg$silentFails === 0) { peg$fail(peg$e143); } } } s3 = []; @@ -5015,7 +5039,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e143); } + if (peg$silentFails === 0) { peg$fail(peg$e144); } } while (s4 !== peg$FAILED) { s3.push(s4); @@ -5024,7 +5048,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e143); } + if (peg$silentFails === 0) { peg$fail(peg$e144); } } } s4 = peg$parseoperator(); @@ -5043,7 +5067,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e144); } + if (peg$silentFails === 0) { peg$fail(peg$e145); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -5052,7 +5076,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e144); } + if (peg$silentFails === 0) { peg$fail(peg$e145); } } } s2 = peg$currPos; @@ -5099,7 +5123,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e145); } + if (peg$silentFails === 0) { peg$fail(peg$e146); } } } } @@ -5115,7 +5139,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e146); } + if (peg$silentFails === 0) { peg$fail(peg$e147); } } if (s0 === peg$FAILED) { s0 = peg$currPos; @@ -5144,24 +5168,24 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c127) { + if (s1.toLowerCase() === peg$c128) { peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e147); } + if (peg$silentFails === 0) { peg$fail(peg$e148); } } if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 12); - if (s1.toLowerCase() === peg$c128) { + if (s1.toLowerCase() === peg$c129) { peg$currPos += 12; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e148); } + if (peg$silentFails === 0) { peg$fail(peg$e149); } } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f121(); + s1 = peg$f122(); } s0 = s1; @@ -5173,24 +5197,24 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c129) { + if (s1.toLowerCase() === peg$c130) { peg$currPos += 14; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e149); } + if (peg$silentFails === 0) { peg$fail(peg$e150); } } if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 15); - if (s1.toLowerCase() === peg$c130) { + if (s1.toLowerCase() === peg$c131) { peg$currPos += 15; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e150); } + if (peg$silentFails === 0) { peg$fail(peg$e151); } } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f122(); + s1 = peg$f123(); } s0 = s1; @@ -5202,24 +5226,24 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c131) { + if (s1.toLowerCase() === peg$c132) { peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e151); } + if (peg$silentFails === 0) { peg$fail(peg$e152); } } if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 12); - if (s1.toLowerCase() === peg$c132) { + if (s1.toLowerCase() === peg$c133) { peg$currPos += 12; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e152); } + if (peg$silentFails === 0) { peg$fail(peg$e153); } } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f123(); + s1 = peg$f124(); } s0 = s1; diff --git a/src/libs/SearchParser/autocompleteParser.peggy b/src/libs/SearchParser/autocompleteParser.peggy index 47773b51e148..faedf94501c3 100644 --- a/src/libs/SearchParser/autocompleteParser.peggy +++ b/src/libs/SearchParser/autocompleteParser.peggy @@ -40,6 +40,12 @@ defaultFilter const isNegated = !!neg && !nonNegatableKeys.has(key); + // The "contains" operator is only supported for the merchant field + if (op === "contains" && (neg || key !== "merchant")) { + autocomplete = null; + return; + } + if (!value) { autocomplete = { key, diff --git a/src/libs/SearchParser/baseRules.peggy b/src/libs/SearchParser/baseRules.peggy index a0e24d1e000e..1abb9f1bb854 100644 --- a/src/libs/SearchParser/baseRules.peggy +++ b/src/libs/SearchParser/baseRules.peggy @@ -191,7 +191,8 @@ groupQuarter = "group-quarter"i &wordBoundary { return "gr operator "operator" - = (":" / "=") { return "eq"; } + = "*:" { return "contains"; } + / (":" / "=") { return "eq"; } / "!=" { return "neq"; } / ">=" { return "gte"; } / ">" { return "gt"; } @@ -201,7 +202,7 @@ operator "operator" filterOperator = o:operator { if (nameOperator) { - expectingNestedQuote = (o === "eq"); // Use simple parser if no valid operator is found + expectingNestedQuote = (o === "eq" || o === "contains"); } isColumnsContext = false; return o; diff --git a/src/libs/SearchParser/searchParser.js b/src/libs/SearchParser/searchParser.js index 68bf5cb29072..1a9f6d7b71be 100644 --- a/src/libs/SearchParser/searchParser.js +++ b/src/libs/SearchParser/searchParser.js @@ -302,21 +302,22 @@ function peg$parse(input, options) { var peg$c115 = "group-week"; var peg$c116 = "group-year"; var peg$c117 = "group-quarter"; - var peg$c118 = "!="; - var peg$c119 = ">="; - var peg$c120 = ">"; - var peg$c121 = "<="; - var peg$c122 = "<"; - var peg$c123 = "\\"; - var peg$c124 = "\u201C"; - var peg$c125 = "\u201D"; - var peg$c126 = "\""; - var peg$c127 = "footercount"; - var peg$c128 = "footer-count"; - var peg$c129 = "footercurrency"; - var peg$c130 = "footer-currency"; - var peg$c131 = "footertotal"; - var peg$c132 = "footer-total"; + var peg$c118 = "*:"; + var peg$c119 = "!="; + var peg$c120 = ">="; + var peg$c121 = ">"; + var peg$c122 = "<="; + var peg$c123 = "<"; + var peg$c124 = "\\"; + var peg$c125 = "\u201C"; + var peg$c126 = "\u201D"; + var peg$c127 = "\""; + var peg$c128 = "footercount"; + var peg$c129 = "footer-count"; + var peg$c130 = "footercurrency"; + var peg$c131 = "footer-currency"; + var peg$c132 = "footertotal"; + var peg$c133 = "footer-total"; var peg$r0 = /^[^ \t\r\n\xA0]/; var peg$r1 = /^[ \t\r\n\xA0,:=<>!]/; @@ -459,36 +460,37 @@ function peg$parse(input, options) { var peg$e122 = peg$literalExpectation("group-year", true); var peg$e123 = peg$literalExpectation("group-quarter", true); var peg$e124 = peg$otherExpectation("operator"); - var peg$e125 = peg$literalExpectation("!=", false); - var peg$e126 = peg$literalExpectation(">=", false); - var peg$e127 = peg$literalExpectation(">", false); - var peg$e128 = peg$literalExpectation("<=", false); - var peg$e129 = peg$literalExpectation("<", false); - var peg$e130 = peg$literalExpectation("\\", false); - var peg$e131 = peg$classExpectation([",", "\"", "\u201D", "\u201C", "\\"], false, false); - var peg$e132 = peg$otherExpectation("word"); - var peg$e133 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e134 = peg$otherExpectation("whitespace"); - var peg$e135 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false); - var peg$e136 = peg$otherExpectation("quote"); - var peg$e137 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false); - var peg$e138 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); - var peg$e139 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); - var peg$e140 = peg$literalExpectation("\u201C", false); - var peg$e141 = peg$literalExpectation("\u201D", false); - var peg$e142 = peg$literalExpectation("\"", false); - var peg$e143 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e144 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false); - var peg$e145 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false); - var peg$e146 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); - var peg$e147 = peg$classExpectation([","], false, false); - var peg$e148 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); - var peg$e149 = peg$literalExpectation("footerCount", true); - var peg$e150 = peg$literalExpectation("footer-count", true); - var peg$e151 = peg$literalExpectation("footerCurrency", true); - var peg$e152 = peg$literalExpectation("footer-currency", true); - var peg$e153 = peg$literalExpectation("footerTotal", true); - var peg$e154 = peg$literalExpectation("footer-total", true); + var peg$e125 = peg$literalExpectation("*:", false); + var peg$e126 = peg$literalExpectation("!=", false); + var peg$e127 = peg$literalExpectation(">=", false); + var peg$e128 = peg$literalExpectation(">", false); + var peg$e129 = peg$literalExpectation("<=", false); + var peg$e130 = peg$literalExpectation("<", false); + var peg$e131 = peg$literalExpectation("\\", false); + var peg$e132 = peg$classExpectation([",", "\"", "\u201D", "\u201C", "\\"], false, false); + var peg$e133 = peg$otherExpectation("word"); + var peg$e134 = peg$classExpectation([" ", ",", "\t", "\n", "\r", "\xA0"], true, false); + var peg$e135 = peg$otherExpectation("whitespace"); + var peg$e136 = peg$classExpectation([" ", "\t", "\r", "\n", "\xA0"], false, false); + var peg$e137 = peg$otherExpectation("quote"); + var peg$e138 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r", "\xA0"], true, false); + var peg$e139 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false); + var peg$e140 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false); + var peg$e141 = peg$literalExpectation("\u201C", false); + var peg$e142 = peg$literalExpectation("\u201D", false); + var peg$e143 = peg$literalExpectation("\"", false); + var peg$e144 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"], ["0", "9"]], false, false); + var peg$e145 = peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false); + var peg$e146 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0"], false, false); + var peg$e147 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ["a", "z"], ["A", "Z"]], false, false); + var peg$e148 = peg$classExpectation([","], false, false); + var peg$e149 = peg$classExpectation([" ", "\t", "\n", "\r", "\xA0", ","], false, false); + var peg$e150 = peg$literalExpectation("footerCount", true); + var peg$e151 = peg$literalExpectation("footer-count", true); + var peg$e152 = peg$literalExpectation("footerCurrency", true); + var peg$e153 = peg$literalExpectation("footer-currency", true); + var peg$e154 = peg$literalExpectation("footerTotal", true); + var peg$e155 = peg$literalExpectation("footer-total", true); var peg$f0 = function(filters) { return applyDefaults(filters); }; var peg$f1 = function(head, tail) { @@ -527,6 +529,10 @@ function peg$parse(input, options) { ); }; var peg$f2 = function(key, op, value) { + // The "contains" operator is only supported for the merchant field + if (op === "contains") { + return buildFilter("eq", "keyword", text().trim()); + } updateDefaultValues(key, value); return {...buildFilter(op, key, value), isDefault: true}; }; @@ -544,7 +550,17 @@ function peg$parse(input, options) { expectingNestedQuote = false; nameOperator = false; const key = field; - let operator = op; + let operator = op.value; + + // Positive `merchant:` filters predate explicit match types, so they must retain contains semantics. + if (!neg && key === "merchant" && op.token === ":") { + operator = "contains"; + } + + // The "contains" operator is only supported for the merchant field + if (operator === "contains" && (neg || key !== "merchant")) { + return buildFilter("eq", "keyword", text().trim()); + } // Apply negation only when "-" is present and the key can be negated if (neg && !nonNegatableKeys.has(key)) { @@ -555,15 +571,16 @@ function peg$parse(input, options) { return buildFilter(operator, key, values); }; - var peg$f5 = function(rest) { + var peg$f5 = function(value) { return {value, token: text()}; }; + var peg$f6 = function(rest) { const suffix = rest.replaceAll(/^-+/g, ""); return "reportField-" + (suffix ? suffix : ""); }; - var peg$f6 = function(k) { + var peg$f7 = function(k) { nameOperator = (k === "from" || k === "to" || k === "payer" || k === "paidBy" || k === "exporter" || k === "attendee" || k === "createdBy" || k === "assignee"); return k; }; - var peg$f7 = function(parts, empty) { + var peg$f8 = function(parts, empty) { const value = parts.flat().filter(Boolean).map((word) => { if (word.startsWith('"') && word.endsWith('"') && word.length >= 2) { return word.slice(1, -1); @@ -575,138 +592,139 @@ function peg$parse(input, options) { } return value[0]; }; - var peg$f8 = function() { return "date"; }; - var peg$f9 = function() { return "amount"; }; - var peg$f10 = function() { return "merchant"; }; - var peg$f11 = function() { return "description"; }; - var peg$f12 = function() { return "reportID"; }; - var peg$f13 = function() { return "keyword"; }; - var peg$f14 = function() { return "in"; }; - var peg$f15 = function() { return "currency"; }; - var peg$f16 = function() { return "groupCurrency"; }; - var peg$f17 = function() { return "tag"; }; - var peg$f18 = function() { return "category"; }; - var peg$f19 = function() { return "to"; }; - var peg$f20 = function() { return "exporter"; }; - var peg$f21 = function() { return "payer"; }; - var peg$f22 = function() { return "paidBy"; }; - var peg$f23 = function() { return "taxRate"; }; - var peg$f24 = function() { return "cardID"; }; - var peg$f25 = function() { return "bankAccount"; }; - var peg$f26 = function() { return "from"; }; - var peg$f27 = function() {return "attendee"}; - var peg$f28 = function() { return "expenseType"; }; - var peg$f29 = function() { return "receiptType"; }; - var peg$f30 = function() { return "transactionStatus"; }; - var peg$f31 = function() { return "withdrawalType"; }; - var peg$f32 = function() { return "withdrawalStatus"; }; - var peg$f33 = function() { return "paidStatus"; }; - var peg$f34 = function() { return "withdrawalID"; }; - var peg$f35 = function() { return "billable"; }; - var peg$f36 = function() { return "reimbursable"; }; - var peg$f37 = function() { return "type"; }; - var peg$f38 = function() { return "status"; }; - var peg$f39 = function() { return "sortBy"; }; - var peg$f40 = function() { return "sortOrder"; }; - var peg$f41 = function() { return "policyID"; }; - var peg$f42 = function() { return "submitted"; }; - var peg$f43 = function() { return "approved"; }; - var peg$f44 = function() { return "paid"; }; - var peg$f45 = function() { return "exported"; }; - var peg$f46 = function() { return "posted"; }; - var peg$f47 = function() { return "withdrawn"; }; - var peg$f48 = function() { return "groupBy"; }; - var peg$f49 = function() { return "limit"; }; - var peg$f50 = function() { return "feed"; }; - var peg$f51 = function() { return "title"; }; - var peg$f52 = function() { return "submitterUserID"; }; - var peg$f53 = function() { return "submitterPayrollID"; }; - var peg$f54 = function() { return "orderDealNumbers"; }; - var peg$f55 = function() { return "assignee"; }; - var peg$f56 = function() { return "createdBy"; }; - var peg$f57 = function() { return "action"; }; - var peg$f58 = function() {return "total"; }; - var peg$f59 = function() {return "has"; }; - var peg$f60 = function() {return "is"; }; - var peg$f61 = function() {return "purchaseAmount"}; - var peg$f62 = function() {return "purchaseCurrency"}; - var peg$f63 = function() {return "amountDebited"}; - var peg$f64 = function() {return "amountReimbursed"}; - var peg$f65 = function() { + var peg$f9 = function() { return "date"; }; + var peg$f10 = function() { return "amount"; }; + var peg$f11 = function() { return "merchant"; }; + var peg$f12 = function() { return "description"; }; + var peg$f13 = function() { return "reportID"; }; + var peg$f14 = function() { return "keyword"; }; + var peg$f15 = function() { return "in"; }; + var peg$f16 = function() { return "currency"; }; + var peg$f17 = function() { return "groupCurrency"; }; + var peg$f18 = function() { return "tag"; }; + var peg$f19 = function() { return "category"; }; + var peg$f20 = function() { return "to"; }; + var peg$f21 = function() { return "exporter"; }; + var peg$f22 = function() { return "payer"; }; + var peg$f23 = function() { return "paidBy"; }; + var peg$f24 = function() { return "taxRate"; }; + var peg$f25 = function() { return "cardID"; }; + var peg$f26 = function() { return "bankAccount"; }; + var peg$f27 = function() { return "from"; }; + var peg$f28 = function() {return "attendee"}; + var peg$f29 = function() { return "expenseType"; }; + var peg$f30 = function() { return "receiptType"; }; + var peg$f31 = function() { return "transactionStatus"; }; + var peg$f32 = function() { return "withdrawalType"; }; + var peg$f33 = function() { return "withdrawalStatus"; }; + var peg$f34 = function() { return "paidStatus"; }; + var peg$f35 = function() { return "withdrawalID"; }; + var peg$f36 = function() { return "billable"; }; + var peg$f37 = function() { return "reimbursable"; }; + var peg$f38 = function() { return "type"; }; + var peg$f39 = function() { return "status"; }; + var peg$f40 = function() { return "sortBy"; }; + var peg$f41 = function() { return "sortOrder"; }; + var peg$f42 = function() { return "policyID"; }; + var peg$f43 = function() { return "submitted"; }; + var peg$f44 = function() { return "approved"; }; + var peg$f45 = function() { return "paid"; }; + var peg$f46 = function() { return "exported"; }; + var peg$f47 = function() { return "posted"; }; + var peg$f48 = function() { return "withdrawn"; }; + var peg$f49 = function() { return "groupBy"; }; + var peg$f50 = function() { return "limit"; }; + var peg$f51 = function() { return "feed"; }; + var peg$f52 = function() { return "title"; }; + var peg$f53 = function() { return "submitterUserID"; }; + var peg$f54 = function() { return "submitterPayrollID"; }; + var peg$f55 = function() { return "orderDealNumbers"; }; + var peg$f56 = function() { return "assignee"; }; + var peg$f57 = function() { return "createdBy"; }; + var peg$f58 = function() { return "action"; }; + var peg$f59 = function() {return "total"; }; + var peg$f60 = function() {return "has"; }; + var peg$f61 = function() {return "is"; }; + var peg$f62 = function() {return "purchaseAmount"}; + var peg$f63 = function() {return "purchaseCurrency"}; + var peg$f64 = function() {return "amountDebited"}; + var peg$f65 = function() {return "amountReimbursed"}; + var peg$f66 = function() { isColumnsContext = true; return "columns"; }; - var peg$f66 = function() {return "view"}; - var peg$f67 = function() {return "compare"}; - var peg$f68 = function() { return isColumnsContext; }; - var peg$f69 = function() { return "perDiem"; }; - var peg$f70 = function() { return "drafts"; }; - var peg$f71 = function() { return "originalamount"; }; - var peg$f72 = function() { return "taxAmount"; }; - var peg$f73 = function() { return "taxrate"; }; - var peg$f74 = function() { return "policyname"; }; - var peg$f75 = function() { return "withdrawalID"; }; - var peg$f76 = function() { return "bankAccount"; }; - var peg$f77 = function() { return "reportID"; }; - var peg$f78 = function() { return "base62ReportID"; }; - var peg$f79 = function() { return "exportedto"; }; - var peg$f80 = function() { return "exportedTo"; }; + var peg$f67 = function() {return "view"}; + var peg$f68 = function() {return "compare"}; + var peg$f69 = function() { return isColumnsContext; }; + var peg$f70 = function() { return "perDiem"; }; + var peg$f71 = function() { return "drafts"; }; + var peg$f72 = function() { return "originalamount"; }; + var peg$f73 = function() { return "taxAmount"; }; + var peg$f74 = function() { return "taxrate"; }; + var peg$f75 = function() { return "policyname"; }; + var peg$f76 = function() { return "withdrawalID"; }; + var peg$f77 = function() { return "bankAccount"; }; + var peg$f78 = function() { return "reportID"; }; + var peg$f79 = function() { return "base62ReportID"; }; + var peg$f80 = function() { return "exportedto"; }; var peg$f81 = function() { return "exportedTo"; }; - var peg$f82 = function() { return "exchangeRate"; }; - var peg$f83 = function() { return "reimbursableTotal"; }; - var peg$f84 = function() { return "nonReimbursableTotal"; }; - var peg$f85 = function() { return "groupFrom"; }; - var peg$f86 = function() { return "groupExpenses"; }; - var peg$f87 = function() { return "groupTotal"; }; - var peg$f88 = function() { return "groupCard"; }; - var peg$f89 = function() { return "groupFeed"; }; - var peg$f90 = function() { return "groupBankAccount"; }; - var peg$f91 = function() { return "groupWithdrawn"; }; - var peg$f92 = function() { return "groupWithdrawalID"; }; - var peg$f93 = function() { return "groupAmountDebited"; }; - var peg$f94 = function() { return "groupAmountReimbursed"; }; - var peg$f95 = function() { return "amountDebited"; }; - var peg$f96 = function() { return "amountReimbursed"; }; - var peg$f97 = function() { return "groupCategory"; }; - var peg$f98 = function() { return "groupTag"; }; - var peg$f99 = function() { return "groupMerchant"; }; - var peg$f100 = function() { return "groupday"; }; - var peg$f101 = function() { return "groupmonth"; }; - var peg$f102 = function() { return "groupweek"; }; - var peg$f103 = function() { return "groupyear"; }; - var peg$f104 = function() { return "groupquarter"; }; - var peg$f105 = function() { return "eq"; }; - var peg$f106 = function() { return "neq"; }; - var peg$f107 = function() { return "gte"; }; - var peg$f108 = function() { return "gt"; }; - var peg$f109 = function() { return "lte"; }; - var peg$f110 = function() { return "lt"; }; - var peg$f111 = function(o) { + var peg$f82 = function() { return "exportedTo"; }; + var peg$f83 = function() { return "exchangeRate"; }; + var peg$f84 = function() { return "reimbursableTotal"; }; + var peg$f85 = function() { return "nonReimbursableTotal"; }; + var peg$f86 = function() { return "groupFrom"; }; + var peg$f87 = function() { return "groupExpenses"; }; + var peg$f88 = function() { return "groupTotal"; }; + var peg$f89 = function() { return "groupCard"; }; + var peg$f90 = function() { return "groupFeed"; }; + var peg$f91 = function() { return "groupBankAccount"; }; + var peg$f92 = function() { return "groupWithdrawn"; }; + var peg$f93 = function() { return "groupWithdrawalID"; }; + var peg$f94 = function() { return "groupAmountDebited"; }; + var peg$f95 = function() { return "groupAmountReimbursed"; }; + var peg$f96 = function() { return "amountDebited"; }; + var peg$f97 = function() { return "amountReimbursed"; }; + var peg$f98 = function() { return "groupCategory"; }; + var peg$f99 = function() { return "groupTag"; }; + var peg$f100 = function() { return "groupMerchant"; }; + var peg$f101 = function() { return "groupday"; }; + var peg$f102 = function() { return "groupmonth"; }; + var peg$f103 = function() { return "groupweek"; }; + var peg$f104 = function() { return "groupyear"; }; + var peg$f105 = function() { return "groupquarter"; }; + var peg$f106 = function() { return "contains"; }; + var peg$f107 = function() { return "eq"; }; + var peg$f108 = function() { return "neq"; }; + var peg$f109 = function() { return "gte"; }; + var peg$f110 = function() { return "gt"; }; + var peg$f111 = function() { return "lte"; }; + var peg$f112 = function() { return "lt"; }; + var peg$f113 = function(o) { if (nameOperator) { - expectingNestedQuote = (o === "eq"); // Use simple parser if no valid operator is found + expectingNestedQuote = (o === "eq" || o === "contains"); } isColumnsContext = false; return o; }; - var peg$f112 = function(c) { return c; }; - var peg$f113 = function(chars) { return chars.join("").trim(); }; - var peg$f114 = function() { + var peg$f114 = function(c) { return c; }; + var peg$f115 = function(chars) { return chars.join("").trim(); }; + var peg$f116 = function() { isColumnsContext = false; return "and"; }; - var peg$f115 = function() { return expectingNestedQuote; }; - var peg$f116 = function(start, inner, end) { //handle no-breaking space + var peg$f117 = function() { return expectingNestedQuote; }; + var peg$f118 = function(start, inner, end) { //handle no-breaking space return [...start, '"', ...inner, '"', ...end].join(""); }; - var peg$f117 = function(start) {return "“"}; - var peg$f118 = function(start) {return "”"}; - var peg$f119 = function(start) {return "\""}; - var peg$f120 = function(start, inner, end) { + var peg$f119 = function(start) {return "“"}; + var peg$f120 = function(start) {return "”"}; + var peg$f121 = function(start) {return "\""}; + var peg$f122 = function(start, inner, end) { return [...start, '"', ...inner, '"'].join(""); }; - var peg$f121 = function() { return "footerCount"; }; - var peg$f122 = function() { return "footerCurrency"; }; - var peg$f123 = function() { return "footerTotal"; }; + var peg$f123 = function() { return "footerCount"; }; + var peg$f124 = function() { return "footerCurrency"; }; + var peg$f125 = function() { return "footerTotal"; }; var peg$currPos = options.peg$currPos | 0; var peg$savedPos = peg$currPos; var peg$posDetailsCache = [{ line: 1, column: 1 }]; @@ -1043,7 +1061,7 @@ function peg$parse(input, options) { s3 = peg$parsefilterKey(); if (s3 !== peg$FAILED) { s4 = peg$parse_(); - s5 = peg$parsefilterOperator(); + s5 = peg$parsesearchFilterOperator(); if (s5 !== peg$FAILED) { s6 = peg$parse_(); s7 = peg$parseidentifier(); @@ -1066,6 +1084,20 @@ function peg$parse(input, options) { return s0; } + function peg$parsesearchFilterOperator() { + var s0, s1; + + s0 = peg$currPos; + s1 = peg$parsefilterOperator(); + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f5(s1); + } + s0 = s1; + + return s0; + } + function peg$parsereportFieldDynamic() { var s0, s1, s2, s3, s4, s5, s6, s7; @@ -1174,7 +1206,7 @@ function peg$parse(input, options) { } s3 = input.substring(s3, peg$currPos); peg$savedPos = s0; - s0 = peg$f5(s3); + s0 = peg$f6(s3); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -1383,7 +1415,7 @@ function peg$parse(input, options) { s1 = peg$parsekey(); if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f6(s1); + s1 = peg$f7(s1); } s0 = s1; @@ -1546,7 +1578,7 @@ function peg$parse(input, options) { s3 = null; } peg$savedPos = s0; - s0 = peg$f7(s2, s3); + s0 = peg$f8(s2, s3); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -1568,7 +1600,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f8(); + s1 = peg$f9(); } s0 = s1; @@ -1588,7 +1620,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f9(); + s1 = peg$f10(); } s0 = s1; @@ -1608,7 +1640,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f10(); + s1 = peg$f11(); } s0 = s1; @@ -1628,7 +1660,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f11(); + s1 = peg$f12(); } s0 = s1; @@ -1656,7 +1688,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f12(); + s1 = peg$f13(); } s0 = s1; } @@ -1677,7 +1709,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f13(); + s1 = peg$f14(); } s0 = s1; @@ -1697,7 +1729,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f14(); + s1 = peg$f15(); } s0 = s1; @@ -1717,7 +1749,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f15(); + s1 = peg$f16(); } s0 = s1; @@ -1745,7 +1777,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f16(); + s1 = peg$f17(); } s0 = s1; } @@ -1766,7 +1798,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f17(); + s1 = peg$f18(); } s0 = s1; @@ -1786,7 +1818,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f18(); + s1 = peg$f19(); } s0 = s1; @@ -1806,7 +1838,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f19(); + s1 = peg$f20(); } s0 = s1; @@ -1826,7 +1858,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f20(); + s1 = peg$f21(); } s0 = s1; @@ -1846,7 +1878,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f21(); + s1 = peg$f22(); } s0 = s1; @@ -1874,7 +1906,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f22(); + s1 = peg$f23(); } s0 = s1; } @@ -1903,7 +1935,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f23(); + s1 = peg$f24(); } s0 = s1; } @@ -1932,7 +1964,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f24(); + s1 = peg$f25(); } s0 = s1; } @@ -1961,7 +1993,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f25(); + s1 = peg$f26(); } s0 = s1; } @@ -1982,7 +2014,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f26(); + s1 = peg$f27(); } s0 = s1; @@ -2002,7 +2034,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f27(); + s1 = peg$f28(); } s0 = s1; @@ -2030,7 +2062,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f28(); + s1 = peg$f29(); } s0 = s1; } @@ -2059,7 +2091,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f29(); + s1 = peg$f30(); } s0 = s1; } @@ -2088,7 +2120,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f30(); + s1 = peg$f31(); } s0 = s1; } @@ -2117,7 +2149,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f31(); + s1 = peg$f32(); } s0 = s1; } @@ -2146,7 +2178,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f32(); + s1 = peg$f33(); } s0 = s1; } @@ -2175,7 +2207,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f33(); + s1 = peg$f34(); } s0 = s1; } @@ -2204,7 +2236,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f34(); + s1 = peg$f35(); } s0 = s1; } @@ -2225,7 +2257,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f35(); + s1 = peg$f36(); } s0 = s1; @@ -2245,7 +2277,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f36(); + s1 = peg$f37(); } s0 = s1; @@ -2265,7 +2297,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f37(); + s1 = peg$f38(); } s0 = s1; @@ -2285,7 +2317,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f38(); + s1 = peg$f39(); } s0 = s1; @@ -2313,7 +2345,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f39(); + s1 = peg$f40(); } s0 = s1; } @@ -2342,7 +2374,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f40(); + s1 = peg$f41(); } s0 = s1; } @@ -2371,7 +2403,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f41(); + s1 = peg$f42(); } s0 = s1; } @@ -2392,7 +2424,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f42(); + s1 = peg$f43(); } s0 = s1; @@ -2412,7 +2444,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f43(); + s1 = peg$f44(); } s0 = s1; @@ -2432,7 +2464,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f44(); + s1 = peg$f45(); } s0 = s1; @@ -2452,7 +2484,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f45(); + s1 = peg$f46(); } s0 = s1; @@ -2472,7 +2504,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f46(); + s1 = peg$f47(); } s0 = s1; @@ -2492,7 +2524,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f47(); + s1 = peg$f48(); } s0 = s1; @@ -2520,7 +2552,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f48(); + s1 = peg$f49(); } s0 = s1; } @@ -2541,7 +2573,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f49(); + s1 = peg$f50(); } s0 = s1; @@ -2561,7 +2593,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f50(); + s1 = peg$f51(); } s0 = s1; @@ -2581,7 +2613,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f51(); + s1 = peg$f52(); } s0 = s1; @@ -2609,7 +2641,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f52(); + s1 = peg$f53(); } s0 = s1; } @@ -2638,7 +2670,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f53(); + s1 = peg$f54(); } s0 = s1; } @@ -2667,7 +2699,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f54(); + s1 = peg$f55(); } s0 = s1; } @@ -2688,7 +2720,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f55(); + s1 = peg$f56(); } s0 = s1; @@ -2716,7 +2748,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f56(); + s1 = peg$f57(); } s0 = s1; } @@ -2737,7 +2769,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f57(); + s1 = peg$f58(); } s0 = s1; @@ -2757,7 +2789,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f58(); + s1 = peg$f59(); } s0 = s1; @@ -2777,7 +2809,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f59(); + s1 = peg$f60(); } s0 = s1; @@ -2797,7 +2829,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f60(); + s1 = peg$f61(); } s0 = s1; @@ -2825,7 +2857,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f61(); + s1 = peg$f62(); } s0 = s1; } @@ -2854,7 +2886,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f62(); + s1 = peg$f63(); } s0 = s1; } @@ -2883,7 +2915,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f63(); + s1 = peg$f64(); } s0 = s1; } @@ -2912,7 +2944,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f64(); + s1 = peg$f65(); } s0 = s1; } @@ -2933,7 +2965,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f65(); + s1 = peg$f66(); } s0 = s1; @@ -2953,7 +2985,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f66(); + s1 = peg$f67(); } s0 = s1; @@ -2973,7 +3005,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f67(); + s1 = peg$f68(); } s0 = s1; @@ -2985,7 +3017,7 @@ function peg$parse(input, options) { s0 = peg$currPos; peg$savedPos = peg$currPos; - s1 = peg$f68(); + s1 = peg$f69(); if (s1) { s1 = undefined; } else { @@ -3139,7 +3171,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f69(); + s0 = peg$f70(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3185,7 +3217,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f70(); + s0 = peg$f71(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3222,7 +3254,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f71(); + s0 = peg$f72(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3259,7 +3291,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f72(); + s0 = peg$f73(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3296,7 +3328,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f73(); + s0 = peg$f74(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3333,7 +3365,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f74(); + s0 = peg$f75(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3370,7 +3402,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f75(); + s0 = peg$f76(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3407,7 +3439,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f76(); + s0 = peg$f77(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3444,7 +3476,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f77(); + s0 = peg$f78(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3481,7 +3513,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f78(); + s0 = peg$f79(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3518,7 +3550,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f79(); + s0 = peg$f80(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3581,7 +3613,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f80(); + s0 = peg$f81(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3612,7 +3644,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f81(); + s0 = peg$f82(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3650,7 +3682,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f82(); + s0 = peg$f83(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3687,7 +3719,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f83(); + s0 = peg$f84(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3724,7 +3756,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f84(); + s0 = peg$f85(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3761,7 +3793,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f85(); + s0 = peg$f86(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3798,7 +3830,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f86(); + s0 = peg$f87(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3835,7 +3867,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f87(); + s0 = peg$f88(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3872,7 +3904,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f88(); + s0 = peg$f89(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3909,7 +3941,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f89(); + s0 = peg$f90(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3946,7 +3978,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f90(); + s0 = peg$f91(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -3983,7 +4015,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f91(); + s0 = peg$f92(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4020,7 +4052,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f92(); + s0 = peg$f93(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4057,7 +4089,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f93(); + s0 = peg$f94(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4094,7 +4126,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f94(); + s0 = peg$f95(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4131,7 +4163,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f95(); + s0 = peg$f96(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4168,7 +4200,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f96(); + s0 = peg$f97(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4205,7 +4237,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f97(); + s0 = peg$f98(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4242,7 +4274,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f98(); + s0 = peg$f99(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4279,7 +4311,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f99(); + s0 = peg$f100(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4316,7 +4348,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f100(); + s0 = peg$f101(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4353,7 +4385,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f101(); + s0 = peg$f102(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4390,7 +4422,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f102(); + s0 = peg$f103(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4427,7 +4459,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f103(); + s0 = peg$f104(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4464,7 +4496,7 @@ function peg$parse(input, options) { } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f104(); + s0 = peg$f105(); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4482,30 +4514,30 @@ function peg$parse(input, options) { peg$silentFails++; s0 = peg$currPos; - s1 = input.charAt(peg$currPos); - if (peg$r2.test(s1)) { - peg$currPos++; + if (input.substr(peg$currPos, 2) === peg$c118) { + s1 = peg$c118; + peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e101); } + if (peg$silentFails === 0) { peg$fail(peg$e125); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f105(); + s1 = peg$f106(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c118) { - s1 = peg$c118; - peg$currPos += 2; + s1 = input.charAt(peg$currPos); + if (peg$r2.test(s1)) { + peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e125); } + if (peg$silentFails === 0) { peg$fail(peg$e101); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f106(); + s1 = peg$f107(); } s0 = s1; if (s0 === peg$FAILED) { @@ -4519,51 +4551,66 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f107(); + s1 = peg$f108(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 62) { + if (input.substr(peg$currPos, 2) === peg$c120) { s1 = peg$c120; - peg$currPos++; + peg$currPos += 2; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e127); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f108(); + s1 = peg$f109(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c121) { + if (input.charCodeAt(peg$currPos) === 62) { s1 = peg$c121; - peg$currPos += 2; + peg$currPos++; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e128); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f109(); + s1 = peg$f110(); } s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 60) { + if (input.substr(peg$currPos, 2) === peg$c122) { s1 = peg$c122; - peg$currPos++; + peg$currPos += 2; } else { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e129); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f110(); + s1 = peg$f111(); } s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 60) { + s1 = peg$c123; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e130); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f112(); + } + s0 = s1; + } } } } @@ -4585,7 +4632,7 @@ function peg$parse(input, options) { s1 = peg$parseoperator(); if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f111(s1); + s1 = peg$f113(s1); } s0 = s1; @@ -4597,11 +4644,11 @@ function peg$parse(input, options) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 92) { - s1 = peg$c123; + s1 = peg$c124; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e130); } + if (peg$silentFails === 0) { peg$fail(peg$e131); } } if (s1 !== peg$FAILED) { s2 = input.charAt(peg$currPos); @@ -4609,11 +4656,11 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e131); } + if (peg$silentFails === 0) { peg$fail(peg$e132); } } if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f112(s2); + s0 = peg$f114(s2); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4639,7 +4686,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e133); } + if (peg$silentFails === 0) { peg$fail(peg$e134); } } } if (s2 !== peg$FAILED) { @@ -4652,7 +4699,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e133); } + if (peg$silentFails === 0) { peg$fail(peg$e134); } } } } @@ -4661,13 +4708,13 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f113(s1); + s1 = peg$f115(s1); } s0 = s1; peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e132); } + if (peg$silentFails === 0) { peg$fail(peg$e133); } } return s0; @@ -4679,7 +4726,7 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = peg$parse_(); peg$savedPos = s0; - s1 = peg$f114(); + s1 = peg$f116(); s0 = s1; return s0; @@ -4695,7 +4742,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e135); } + if (peg$silentFails === 0) { peg$fail(peg$e136); } } while (s1 !== peg$FAILED) { s0.push(s1); @@ -4704,12 +4751,12 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e135); } + if (peg$silentFails === 0) { peg$fail(peg$e136); } } } peg$silentFails--; s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e134); } + if (peg$silentFails === 0) { peg$fail(peg$e135); } return s0; } @@ -4719,7 +4766,7 @@ function peg$parse(input, options) { s0 = peg$currPos; peg$savedPos = peg$currPos; - s1 = peg$f115(); + s1 = peg$f117(); if (s1) { s1 = undefined; } else { @@ -4756,7 +4803,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e137); } + if (peg$silentFails === 0) { peg$fail(peg$e138); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4765,7 +4812,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e137); } + if (peg$silentFails === 0) { peg$fail(peg$e138); } } } s2 = input.charAt(peg$currPos); @@ -4773,7 +4820,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e138); } + if (peg$silentFails === 0) { peg$fail(peg$e139); } } if (s2 !== peg$FAILED) { s3 = []; @@ -4784,7 +4831,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e139); } + if (peg$silentFails === 0) { peg$fail(peg$e140); } } } while (s4 !== peg$FAILED) { @@ -4796,7 +4843,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e139); } + if (peg$silentFails === 0) { peg$fail(peg$e140); } } } } @@ -4805,7 +4852,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e138); } + if (peg$silentFails === 0) { peg$fail(peg$e139); } } if (s4 !== peg$FAILED) { s5 = []; @@ -4814,7 +4861,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e133); } + if (peg$silentFails === 0) { peg$fail(peg$e134); } } while (s6 !== peg$FAILED) { s5.push(s6); @@ -4823,11 +4870,11 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e133); } + if (peg$silentFails === 0) { peg$fail(peg$e134); } } } peg$savedPos = s0; - s0 = peg$f116(s1, s3, s5); + s0 = peg$f118(s1, s3, s5); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -4839,7 +4886,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e136); } + if (peg$silentFails === 0) { peg$fail(peg$e137); } } return s0; @@ -4856,7 +4903,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e137); } + if (peg$silentFails === 0) { peg$fail(peg$e138); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -4865,7 +4912,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e137); } + if (peg$silentFails === 0) { peg$fail(peg$e138); } } } s2 = input.charAt(peg$currPos); @@ -4873,7 +4920,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e138); } + if (peg$silentFails === 0) { peg$fail(peg$e139); } } if (s2 !== peg$FAILED) { s3 = []; @@ -4884,7 +4931,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e139); } + if (peg$silentFails === 0) { peg$fail(peg$e140); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -4900,15 +4947,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c124; + s6 = peg$c125; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e140); } + if (peg$silentFails === 0) { peg$fail(peg$e141); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f117(s1); + s4 = peg$f119(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4931,15 +4978,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c125; + s6 = peg$c126; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e141); } + if (peg$silentFails === 0) { peg$fail(peg$e142); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f118(s1); + s4 = peg$f120(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4962,15 +5009,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c126; + s6 = peg$c127; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e142); } + if (peg$silentFails === 0) { peg$fail(peg$e143); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f119(s1); + s4 = peg$f121(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -4992,7 +5039,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e139); } + if (peg$silentFails === 0) { peg$fail(peg$e140); } } if (s4 === peg$FAILED) { s4 = peg$currPos; @@ -5008,15 +5055,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8220) { - s6 = peg$c124; + s6 = peg$c125; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e140); } + if (peg$silentFails === 0) { peg$fail(peg$e141); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f117(s1); + s4 = peg$f119(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -5039,15 +5086,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 8221) { - s6 = peg$c125; + s6 = peg$c126; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e141); } + if (peg$silentFails === 0) { peg$fail(peg$e142); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f118(s1); + s4 = peg$f120(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -5070,15 +5117,15 @@ function peg$parse(input, options) { } if (s5 !== peg$FAILED) { if (input.charCodeAt(peg$currPos) === 34) { - s6 = peg$c126; + s6 = peg$c127; peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e142); } + if (peg$silentFails === 0) { peg$fail(peg$e143); } } if (s6 !== peg$FAILED) { peg$savedPos = s4; - s4 = peg$f119(s1); + s4 = peg$f121(s1); } else { peg$currPos = s4; s4 = peg$FAILED; @@ -5095,7 +5142,7 @@ function peg$parse(input, options) { s4 = peg$parseclosingQuote(); if (s4 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f120(s1, s3, s4); + s0 = peg$f122(s1, s3, s4); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -5107,7 +5154,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e136); } + if (peg$silentFails === 0) { peg$fail(peg$e137); } } return s0; @@ -5122,7 +5169,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e138); } + if (peg$silentFails === 0) { peg$fail(peg$e139); } } if (s1 !== peg$FAILED) { s2 = peg$currPos; @@ -5160,7 +5207,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e143); } + if (peg$silentFails === 0) { peg$fail(peg$e144); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -5169,7 +5216,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e143); } + if (peg$silentFails === 0) { peg$fail(peg$e144); } } } s2 = []; @@ -5178,7 +5225,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e144); } + if (peg$silentFails === 0) { peg$fail(peg$e145); } } while (s3 !== peg$FAILED) { s2.push(s3); @@ -5187,7 +5234,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e144); } + if (peg$silentFails === 0) { peg$fail(peg$e145); } } } s3 = []; @@ -5196,7 +5243,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e145); } + if (peg$silentFails === 0) { peg$fail(peg$e146); } } while (s4 !== peg$FAILED) { s3.push(s4); @@ -5205,7 +5252,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e145); } + if (peg$silentFails === 0) { peg$fail(peg$e146); } } } s4 = peg$parseoperator(); @@ -5224,7 +5271,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e146); } + if (peg$silentFails === 0) { peg$fail(peg$e147); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -5233,7 +5280,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e146); } + if (peg$silentFails === 0) { peg$fail(peg$e147); } } } s2 = peg$currPos; @@ -5280,7 +5327,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e147); } + if (peg$silentFails === 0) { peg$fail(peg$e148); } } } } @@ -5296,7 +5343,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s0 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e148); } + if (peg$silentFails === 0) { peg$fail(peg$e149); } } if (s0 === peg$FAILED) { s0 = peg$currPos; @@ -5325,24 +5372,24 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c127) { + if (s1.toLowerCase() === peg$c128) { peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e149); } + if (peg$silentFails === 0) { peg$fail(peg$e150); } } if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 12); - if (s1.toLowerCase() === peg$c128) { + if (s1.toLowerCase() === peg$c129) { peg$currPos += 12; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e150); } + if (peg$silentFails === 0) { peg$fail(peg$e151); } } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f121(); + s1 = peg$f123(); } s0 = s1; @@ -5354,24 +5401,24 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 14); - if (s1.toLowerCase() === peg$c129) { + if (s1.toLowerCase() === peg$c130) { peg$currPos += 14; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e151); } + if (peg$silentFails === 0) { peg$fail(peg$e152); } } if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 15); - if (s1.toLowerCase() === peg$c130) { + if (s1.toLowerCase() === peg$c131) { peg$currPos += 15; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e152); } + if (peg$silentFails === 0) { peg$fail(peg$e153); } } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f122(); + s1 = peg$f124(); } s0 = s1; @@ -5383,24 +5430,24 @@ function peg$parse(input, options) { s0 = peg$currPos; s1 = input.substr(peg$currPos, 11); - if (s1.toLowerCase() === peg$c131) { + if (s1.toLowerCase() === peg$c132) { peg$currPos += 11; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e153); } + if (peg$silentFails === 0) { peg$fail(peg$e154); } } if (s1 === peg$FAILED) { s1 = input.substr(peg$currPos, 12); - if (s1.toLowerCase() === peg$c132) { + if (s1.toLowerCase() === peg$c133) { peg$currPos += 12; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e154); } + if (peg$silentFails === 0) { peg$fail(peg$e155); } } } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f123(); + s1 = peg$f125(); } s0 = s1; diff --git a/src/libs/SearchParser/searchParser.peggy b/src/libs/SearchParser/searchParser.peggy index 3a469f4bf432..a0f8305422fb 100644 --- a/src/libs/SearchParser/searchParser.peggy +++ b/src/libs/SearchParser/searchParser.peggy @@ -216,6 +216,10 @@ filter = @(standardFilter / defaultFilter / freeTextFilter) defaultFilter = _ key:defaultKey _ op:operator _ value:identifier { + // The "contains" operator is only supported for the merchant field + if (op === "contains") { + return buildFilter("eq", "keyword", text().trim()); + } updateDefaultValues(key, value); return {...buildFilter(op, key, value), isDefault: true}; } @@ -233,11 +237,21 @@ freeTextFilter } standardFilter - = _ neg:"-"? field:filterKey _ op:filterOperator _ values:identifier { + = _ neg:"-"? field:filterKey _ op:searchFilterOperator _ values:identifier { expectingNestedQuote = false; nameOperator = false; const key = field; - let operator = op; + let operator = op.value; + + // Positive `merchant:` filters predate explicit match types, so they must retain contains semantics. + if (!neg && key === "merchant" && op.token === ":") { + operator = "contains"; + } + + // The "contains" operator is only supported for the merchant field + if (operator === "contains" && (neg || key !== "merchant")) { + return buildFilter("eq", "keyword", text().trim()); + } // Apply negation only when "-" is present and the key can be negated if (neg && !nonNegatableKeys.has(key)) { @@ -249,6 +263,9 @@ standardFilter return buildFilter(operator, key, values); } +searchFilterOperator + = value:filterOperator { return {value, token: text()}; } + reportFieldDynamic = ("report-field"i / "reportfield"i) "-" rest:$((!([ \t\r\n\xA0,:=<>!]) .)*) { const suffix = rest.replaceAll(/^-+/g, ""); diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index d19de92dfd7a..5f3f9868ec96 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -41,6 +41,7 @@ import type { HasFilterValues, IsFilterValue, IsFilterValues, + MerchantMatchType, ReceiptTypeValue, TransactionStatusValue, SearchAdvancedFiltersKey, @@ -104,6 +105,58 @@ const operatorToCharMap = { [CONST.SEARCH.SYNTAX_OPERATORS.AND]: ',' as const, [CONST.SEARCH.SYNTAX_OPERATORS.OR]: ' ' as const, }; +const EXPLICIT_EQUAL_TO_OPERATOR = '=' as const; + +const DEFAULT_MERCHANT_OPERATOR = CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS; + +function getMerchantOperator(operator: MerchantMatchType | undefined): MerchantMatchType { + return operator ?? DEFAULT_MERCHANT_OPERATOR; +} + +/** + * Builds the Merchant part of the query. The form has one Merchant field, so a changed condition updates every Merchant clause from flatFilters. + */ +function buildMerchantFilterQuery( + value: string, + prefix: string, + isNegated: boolean, + isMerchantNegated: boolean, + merchantOperator: MerchantMatchType | undefined, + flatFilters: QueryFilters | undefined, +): string { + const merchantFilters = flatFilters?.filter((filter) => filter.key === FILTER_KEYS.MERCHANT) ?? []; + const positiveMerchantFilters = merchantFilters.filter((filter) => !filter.filters.some((item) => item.operator === CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO)); + const negativeMerchantFilters = merchantFilters.filter((filter) => filter.filters.some((item) => item.operator === CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO)); + const wasMerchantNegated = negativeMerchantFilters.length > 0; + const hasMerchantNegationChanged = wasMerchantNegated !== isMerchantNegated; + const lastVisibleMerchantFilter = (wasMerchantNegated ? negativeMerchantFilters : positiveMerchantFilters).at(-1); + const selectedMerchantOperator = getMerchantOperator(merchantOperator); + const originalMerchantFilters = isNegated ? negativeMerchantFilters : positiveMerchantFilters; + const lastMerchantFilter = hasMerchantNegationChanged ? lastVisibleMerchantFilter : originalMerchantFilters.at(-1); + const isMerchantValueUnchanged = value === lastMerchantFilter?.filters.map((item) => item.value.toString()).join(','); + if (lastMerchantFilter && isMerchantValueUnchanged) { + const currentMerchantOperator = positiveMerchantFilters.at(-1)?.filters.some((item) => item.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO) + ? CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO + : DEFAULT_MERCHANT_OPERATOR; + const hasMerchantOperatorChanged = !isMerchantNegated && selectedMerchantOperator !== currentMerchantOperator; + const hasMerchantConditionChanged = hasMerchantNegationChanged || hasMerchantOperatorChanged; + const filtersToUpdate = hasMerchantConditionChanged ? merchantFilters : originalMerchantFilters; + const updatedOperator = isNegated ? CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO : selectedMerchantOperator; + // The single field cannot represent every clause or distinguish lists from names containing commas. + const merchantClauses = filtersToUpdate.map((filter) => { + const clauseFilters = hasMerchantConditionChanged ? filter.filters.map((item) => ({...item, operator: updatedOperator})) : filter.filters; + return buildFilterValuesString(FILTER_KEYS.MERCHANT, clauseFilters).trim(); + }); + // Different operators can become identical clauses after the change. + return [...new Set(merchantClauses)].join(' '); + } + + let operator: ValueOf | typeof EXPLICIT_EQUAL_TO_OPERATOR = operatorToCharMap[CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO]; + if (!isNegated) { + operator = selectedMerchantOperator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO ? EXPLICIT_EQUAL_TO_OPERATOR : operatorToCharMap[CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS]; + } + return `${prefix}${CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT}${operator}${sanitizeSearchValue(value)}`; +} // Pre-computed validation Sets for buildFilterFormValuesFromQuery (avoids recreating per filter iteration) const VALID_EXPENSE_TYPES = new Set(Object.values(CONST.SEARCH.TRANSACTION_TYPE)); @@ -387,7 +440,6 @@ function sanitizeIncompleteQuotedValue(str: string) { const sanitized = sanitizeSearchValue(str); return sanitized.startsWith('"') ? sanitized : `"${sanitized}"`; } - /** * Escapes each keyword that would otherwise be re-interpreted as query syntax by wrapping it in quotes. * A keyword that looks like a filter (e.g. `type:expense`) becomes `"type:expense"` so it is matched as a @@ -594,13 +646,16 @@ function buildAmountFilterQuery(filterKey: SearchAmountFilterKeys, filterValues: function buildFilterValuesString(filterName: string, queryFilters: QueryFilter[]) { const delimiter = filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD ? ' ' : ','; const allowedOps = new Set([CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO]); + if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT) { + allowedOps.add(CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS); + } let filterValueString = ''; for (const [index, queryFilter] of queryFilters.entries()) { const previousValueHasSameOp = allowedOps.has(queryFilter.operator) && queryFilters?.at(index - 1)?.operator === queryFilter.operator; const nextValueHasSameOp = allowedOps.has(queryFilter.operator) && queryFilters?.at(index + 1)?.operator === queryFilter.operator; - // If the previous queryFilter has the same operator (this rule applies only to eq and neq operators) then append the current value + // If the previous queryFilter has the same operator and that operator supports comma-delimited values, append the current value if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD) { filterValueString += `${delimiter}${escapeKeyword(sanitizeSearchValue(queryFilter.value.toString()))}`; } else if (index !== 0 && (previousValueHasSameOp || nextValueHasSameOp)) { @@ -618,7 +673,10 @@ function buildFilterValuesString(filterName: string, queryFilters: QueryFilter[] filterValueString += ` ${filterName}${operatorToCharMap[CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO]}${sanitizeSearchValue(rangeBoundaries.to)}`; } } else { - const operatorChar = operatorToCharMap[queryFilter.operator]; + const operatorChar = + filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT && queryFilter.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO + ? EXPLICIT_EQUAL_TO_OPERATOR + : operatorToCharMap[queryFilter.operator]; if (!operatorChar) { continue; } @@ -929,12 +987,6 @@ function getQueryHashes(query: SearchQueryJSON) { } const filterSet = new Set(orderedQuery); - const exactMatchFilterKeys = [...(query.exactMatchFilterKeys ?? [])].sort(); - if (exactMatchFilterKeys.length > 0) { - const exactMatchIdentity = `exactMatch:${exactMatchFilterKeys.join(',')}`; - orderedQuery += ` ${exactMatchIdentity}`; - filterSet.add(exactMatchIdentity); - } // Certain filters shouldn't affect whether two searchers are similar or not, since they dont // actually filter out results @@ -1005,17 +1057,6 @@ function getQueryHashes(query: SearchQueryJSON) { return {primaryHash, recentSearchHash, similarSearchHash}; } -function withExactMatchFilterKeys(queryJSON: Readonly, exactMatchFilterKeys: SearchFilterKey[]): SearchQueryJSON { - const queryWithExactMatches = {...queryJSON, exactMatchFilterKeys}; - const {primaryHash, recentSearchHash, similarSearchHash} = getQueryHashes(queryWithExactMatches); - return { - ...queryWithExactMatches, - hash: primaryHash, - recentSearchHash, - similarSearchHash, - }; -} - /** * Returns whether a given string is a date preset (e.g. Last month) */ @@ -1268,6 +1309,8 @@ type BuildQueryStringOptions = { sortBy?: string; sortOrder?: string; limit?: number; + /** Original query filters, including predicates that cannot fit in a single form field. */ + flatFilters?: QueryFilters; }; /** @@ -1305,7 +1348,7 @@ function buildQueryStringFromFilterFormValues(filterValues: Partial CONST.SEARCH.SYNTAX_FILTER_KEYS[key] === filterKey); if (keyInCorrectForm) { - return `${prefix}${CONST.SEARCH.SYNTAX_FILTER_KEYS[keyInCorrectForm]}:${sanitizeSearchValue(filterValue as string)}`; + const value = filterValue as string; + if (filterKey === FILTER_KEYS.MERCHANT) { + return buildMerchantFilterQuery(value, prefix, isNegated, !!supportedFilterValues.merchantNot, merchantOperator, options?.flatFilters); + } + return `${prefix}${CONST.SEARCH.SYNTAX_FILTER_KEYS[keyInCorrectForm]}${operatorToCharMap[CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO]}${sanitizeSearchValue(value)}`; } } if ((filterKey === FILTER_KEYS.REPORT_ID || filterKey === FILTER_KEYS.WITHDRAWAL_ID) && filterValue) { @@ -1797,6 +1844,11 @@ function buildFilterFormValuesFromQuery( } if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT || filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION || filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TITLE) { filtersForm[addNegation(filterKey, isNegated)] = filterValues.join(','); + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT && !isNegated) { + filtersForm[FILTER_KEYS.MERCHANT_OPERATOR] = filterList.some((item) => item.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO) + ? CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO + : DEFAULT_MERCHANT_OPERATOR; + } } if ( filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.SUBMITTER_USER_ID || @@ -2883,7 +2935,7 @@ function shouldResetSortForViewChange({newView, oldView, groupBy}: {newView: str function buildFilterQueryWithSortDefaults( filterValues: Partial, previousState: {view?: string; groupBy?: string}, - currentQueryOptions: {sortBy?: string; sortOrder?: string}, + currentQueryOptions: BuildQueryStringOptions, policies?: OnyxCollection, ): string | undefined { const resetSort = shouldResetSort({ @@ -2902,6 +2954,7 @@ function buildFilterQueryWithSortDefaults( const queryString = buildQueryStringFromFilterFormValues(filterValues, { sortBy: resetSort || resetSortForViewChange ? undefined : currentQueryOptions.sortBy, sortOrder: resetSort || resetSortForViewChange ? undefined : currentQueryOptions.sortOrder, + flatFilters: currentQueryOptions.flatFilters, }); if (!resetSort && !resetSortForViewChange) { @@ -2974,30 +3027,27 @@ function getEmptyDateValues(): SearchDateValues { } /** - * Set of filter keys that represent free-text fields where the default `:` (eq) operator - * should be treated as a substring/partial match (`contains`) when querying the backend. - * This allows searches like `merchant:coffee` to match "Coffee shop". + * Fields where `eq` should still be sent to the backend as `contains`. + * Merchant is excluded because the parser preserves legacy `merchant:` as contains and uses `merchant=` for exact matches. */ function isTextSearchField(key: string): key is SearchFilterKey { - return key === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT || key === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION; + return key === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION; } /** * Recursively traverses a search AST and replaces the `eq` operator with `contains` - * for free-text filter fields (merchant, description). This enables partial/substring - * matching on the backend for text searches while preserving the user-facing `:` syntax. - * Keys in `exactMatchFilterKeys` keep their original `eq` operator. + * for fields that still use partial matching on the backend (description). + * Merchant is excluded because its parser output already distinguishes legacy contains from explicit exact matches. */ -function applyContainsOperatorToTextFields(node: ASTNode, exactMatchFilterKeys?: ReadonlySet): ASTNode { - const filterKey = typeof node.left === 'string' && isTextSearchField(node.left) ? node.left : undefined; - if (filterKey && !exactMatchFilterKeys?.has(filterKey) && node.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO) { +function applyContainsOperatorToTextFields(node: ASTNode): ASTNode { + if (typeof node.left === 'string' && isTextSearchField(node.left) && node.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO) { return {...node, operator: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS}; } return { ...node, - left: typeof node.left === 'object' && node.left ? applyContainsOperatorToTextFields(node.left, exactMatchFilterKeys) : node.left, - right: typeof node.right === 'object' && !Array.isArray(node.right) && node.right ? applyContainsOperatorToTextFields(node.right, exactMatchFilterKeys) : node.right, + left: typeof node.left === 'object' && node.left ? applyContainsOperatorToTextFields(node.left) : node.left, + right: typeof node.right === 'object' && !Array.isArray(node.right) && node.right ? applyContainsOperatorToTextFields(node.right) : node.right, }; } @@ -3017,16 +3067,15 @@ function getDateModifierTitle(modifier: ValueOf(queryData: T, exactMatchFilterKeys?: ReadonlySet): string { - const normalizedFilters = queryData.filters ? applyContainsOperatorToTextFields(queryData.filters, exactMatchFilterKeys) : queryData.filters; +function serializeQueryJSONForBackend(queryData: T): string { + const normalizedFilters = queryData.filters ? applyContainsOperatorToTextFields(queryData.filters) : queryData.filters; const normalizedRawFilterList = queryData.rawFilterList ? queryData.rawFilterList.map((filter) => { - if (isTextSearchField(filter.key) && !exactMatchFilterKeys?.has(filter.key) && filter.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO) { + if (isTextSearchField(filter.key) && filter.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO) { return {...filter, operator: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS}; } return filter; @@ -3118,7 +3167,6 @@ export { hasFooterSelections, hasFiltersChangedFromDefault, isSearchQuerySavable, - withExactMatchFilterKeys, isSearchDatePreset, getDateRangeForPreset, getDateFilterRange, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index b7f78c261878..84ff54abab2b 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -214,7 +214,6 @@ import { isSearchDatePreset, removeNegation, sortOptionsWithEmptyValue, - withExactMatchFilterKeys, } from './SearchQueryUtils'; import {expenseStatusActionMapping, getSuggestedSearches, isEligibleForStatus, SEARCH_TYPE_MENU_ICON_NAMES} from './SearchSuggestionUtils'; import StringUtils from './StringUtils'; @@ -3056,11 +3055,7 @@ function buildSpecificGroupQuery(queryJSON: SearchQueryJSON, filterKey: QueryFil sortOrder: CONST.SEARCH.SORT_ORDER.DESC, flatFilters: newFlatFilters, }; - const specificGroupQueryJSON = buildSearchQueryJSON(buildSearchQueryString(newQueryJSON)); - if (!specificGroupQueryJSON || filterKey !== CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT) { - return specificGroupQueryJSON; - } - return withExactMatchFilterKeys(specificGroupQueryJSON, [filterKey]); + return buildSearchQueryJSON(buildSearchQueryString(newQueryJSON)); } function getActiveGroupSearchHashes(data: OnyxTypes.SearchResults['data'] | undefined, queryJSON: Readonly | undefined): number[] { @@ -6047,6 +6042,16 @@ function getDisplayValue( return form[key]?.map((receiptType) => translate(getReceiptTypeTranslationKey(receiptType))).join(', '); } + if (key === FILTER_KEYS.MERCHANT) { + const merchant = form[key]; + if (!merchant) { + return; + } + + const matchTypeKey = form[FILTER_KEYS.MERCHANT_OPERATOR] === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO ? 'search.filters.merchant.equalTo' : 'search.filters.merchant.contains'; + return translate(matchTypeKey, merchant); + } + if (key === FILTER_KEYS.TRANSACTION_STATUS) { const transactionStatus = form[key]; return transactionStatus ? translate(`search.filters.transactionStatus.${transactionStatus}`) : undefined; @@ -6099,6 +6104,11 @@ function hasFilterContentValuesChanged( return true; } + // The Merchant content also reads its match type. + if (baseFilterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT) { + return previousValues?.[FILTER_KEYS.MERCHANT_OPERATOR] !== values?.[FILTER_KEYS.MERCHANT_OPERATOR]; + } + if (isTextFilterKey(baseFilterKey)) { return false; } diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 4e0bbab9bcb4..58f578a30cfb 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -1265,7 +1265,7 @@ function handlePreventSearchAPI(hash: number | undefined) { * columns to a backend-supported date sort. */ function getBackendQueryJSON(queryJSON: Readonly) { - const {exactMatchFilterKeys, flatFilters, limit, ...queryJSONWithoutFlatFilters} = queryJSON; + const {flatFilters, limit, ...queryJSONWithoutFlatFilters} = queryJSON; const backendQueryJSON = shouldUseBackendDateSortFallback(queryJSON.sortBy) ? { ...queryJSONWithoutFlatFilters, @@ -1279,7 +1279,7 @@ function getBackendQueryJSON(queryJSON: Readonly) { ), } : queryJSONWithoutFlatFilters; - return {backendQueryJSON, limit, exactMatchFilterKeys}; + return {backendQueryJSON, limit}; } function search({ @@ -1364,7 +1364,7 @@ function search({ inFlightSearchRequests.set(dedupeKey, inFlightRequestState); const onyxLoadingData = getOnyxLoadingData(queryJSON.hash, queryJSON, offset, true, shouldCalculateTotals, shouldShowLoading); - const {backendQueryJSON, limit, exactMatchFilterKeys} = getBackendQueryJSON(queryJSON); + const {backendQueryJSON, limit} = getBackendQueryJSON(queryJSON); const query = { ...backendQueryJSON, searchKey, @@ -1375,7 +1375,7 @@ function search({ // Backend expects 'maximumResults' instead of 'limit' ...(limit !== undefined && {maximumResults: limit}), }; - const jsonQuery = serializeQueryJSONForBackend(query, exactMatchFilterKeys ? new Set(exactMatchFilterKeys) : undefined); + const jsonQuery = serializeQueryJSONForBackend(query); if (shouldUpdateLastSearchParams) { saveLastSearchParams({ @@ -1554,15 +1554,12 @@ function getFooterConvertedAmounts({ // searchKey changes what the backend query matches (e.g. unapprovedCash excludes card expenses), so it must be // sent exactly as search() sends it or the converted totals cover a different expense set than the snapshot. - const {backendQueryJSON, exactMatchFilterKeys} = getBackendQueryJSON(queryJSON); - const jsonQuery = serializeQueryJSONForBackend( - { - ...backendQueryJSON, - searchKey, - filters: backendQueryJSON.filters ?? null, - }, - exactMatchFilterKeys ? new Set(exactMatchFilterKeys) : undefined, - ); + const {backendQueryJSON} = getBackendQueryJSON(queryJSON); + const jsonQuery = serializeQueryJSONForBackend({ + ...backendQueryJSON, + searchKey, + filters: backendQueryJSON.filters ?? null, + }); read( READ_COMMANDS.GET_TRANSACTIONS_CONVERTED_AMOUNT, diff --git a/src/pages/Search/SearchAdvancedFiltersContentPage/MerchantFilterContentPageWrapper.tsx b/src/pages/Search/SearchAdvancedFiltersContentPage/MerchantFilterContentPageWrapper.tsx new file mode 100644 index 000000000000..d49a1e36d85a --- /dev/null +++ b/src/pages/Search/SearchAdvancedFiltersContentPage/MerchantFilterContentPageWrapper.tsx @@ -0,0 +1,23 @@ +import MerchantFilterContent from '@components/Search/FilterComponents/AdvancedFilters/MerchantFilterContent'; +import type {MerchantFilterContentWrapperProps} from '@components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +function MerchantFilterContentPageWrapper({baseFilterKey, value, isNegated, merchantOperator, buttonText, onChange}: MerchantFilterContentWrapperProps) { + return ( + + ); +} + +export default MerchantFilterContentPageWrapper; diff --git a/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx b/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx index 31e9652459fa..d93abad75c4b 100644 --- a/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx +++ b/src/pages/Search/SearchAdvancedFiltersContentPage/SearchAdvancedFiltersContentBase.tsx @@ -33,6 +33,7 @@ import {View} from 'react-native'; import AmountFilterContentPageWrapper from './AmountFilterContentPageWrapper'; import DateFilterContentPageWrapper from './DateFilterContentPageWrapper'; import ListFilterContentPageWrapper from './ListFilterContentPageWrapper'; +import MerchantFilterContentPageWrapper from './MerchantFilterContentPageWrapper'; import ReportFieldFilterContentPageWrapper from './ReportFieldFilterContentPageWrapper'; import TextInputFilterContentPageWrapper from './TextInputFilterContentPageWrapper'; @@ -115,6 +116,7 @@ function SearchAdvancedFiltersContentBase() { ready={didScreenTransitionEnd} components={{ List: ListFilterContentPageWrapper, + Merchant: MerchantFilterContentPageWrapper, Text: TextInputFilterContentPageWrapper, Amount: AmountFilterContentPageWrapper, Date: DateFilterContentPageWrapper, diff --git a/src/pages/Search/SearchColumnsPage.tsx b/src/pages/Search/SearchColumnsPage.tsx index f13130a0df52..01703b4be243 100644 --- a/src/pages/Search/SearchColumnsPage.tsx +++ b/src/pages/Search/SearchColumnsPage.tsx @@ -109,6 +109,7 @@ function SearchColumnsPage() { const queryString = buildQueryStringFromFilterFormValues(updatedAdvancedFilters, { sortBy: currentQueryJSON?.sortBy, sortOrder: currentQueryJSON?.sortOrder, + flatFilters: currentQueryJSON?.flatFilters, }); // Only the columns change, so it's still the same search - carry the key over rather than letting it be diff --git a/src/types/form/SearchAdvancedFiltersForm.ts b/src/types/form/SearchAdvancedFiltersForm.ts index f860ec0faaaf..36cbc8dfad26 100644 --- a/src/types/form/SearchAdvancedFiltersForm.ts +++ b/src/types/form/SearchAdvancedFiltersForm.ts @@ -152,6 +152,7 @@ const FILTER_KEYS = { MERCHANT_NOT: 'merchantNot', MERCHANT: 'merchant', + MERCHANT_OPERATOR: 'merchantOperator', DESCRIPTION_NOT: 'descriptionNot', DESCRIPTION: 'description', @@ -293,6 +294,7 @@ const ALLOWED_TYPE_FILTERS: Record> = { FILTER_KEYS.TRANSACTION_STATUS_NOT, FILTER_KEYS.MERCHANT, FILTER_KEYS.MERCHANT_NOT, + FILTER_KEYS.MERCHANT_OPERATOR, FILTER_KEYS.DATE_ON, FILTER_KEYS.DATE_NOT, FILTER_KEYS.DATE_AFTER, @@ -504,6 +506,7 @@ const ALLOWED_TYPE_FILTERS: Record> = { FILTER_KEYS.POLICY_ID_NOT, FILTER_KEYS.MERCHANT, FILTER_KEYS.MERCHANT_NOT, + FILTER_KEYS.MERCHANT_OPERATOR, FILTER_KEYS.DATE_ON, FILTER_KEYS.DATE_NOT, FILTER_KEYS.DATE_AFTER, @@ -602,6 +605,7 @@ const ALLOWED_TYPE_FILTERS: Record> = { FILTER_KEYS.POLICY_ID_NOT, FILTER_KEYS.MERCHANT, FILTER_KEYS.MERCHANT_NOT, + FILTER_KEYS.MERCHANT_OPERATOR, FILTER_KEYS.DATE_ON, FILTER_KEYS.DATE_NOT, FILTER_KEYS.DATE_AFTER, @@ -732,6 +736,7 @@ type IsFilterValues = IsFilterValue[]; type BooleanValue = ValueOf; type ExpenseTypeValue = ValueOf; type ExpenseTypeValues = ExpenseTypeValue[]; +type MerchantMatchType = typeof CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO | typeof CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS; type ReceiptTypeValue = ValueOf; type ReceiptTypeValues = ReceiptTypeValue[]; type TransactionStatusValue = ValueOf; @@ -819,6 +824,7 @@ type SearchAdvancedFiltersForm = Form< [FILTER_KEYS.MERCHANT]: string; [FILTER_KEYS.MERCHANT_NOT]: string; + [FILTER_KEYS.MERCHANT_OPERATOR]: MerchantMatchType; [FILTER_KEYS.DESCRIPTION]: string; [FILTER_KEYS.DESCRIPTION_NOT]: string; @@ -950,6 +956,7 @@ export type { IsFilterValues, ExpenseTypeValue, ExpenseTypeValues, + MerchantMatchType, ReceiptTypeValue, TransactionStatusValue, SearchNegatableFilterKeys, diff --git a/tests/ui/MerchantFilterContentTest.tsx b/tests/ui/MerchantFilterContentTest.tsx new file mode 100644 index 000000000000..be459e4789fb --- /dev/null +++ b/tests/ui/MerchantFilterContentTest.tsx @@ -0,0 +1,136 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import MerchantFilterContent from '@components/Search/FilterComponents/AdvancedFilters/MerchantFilterContent'; +import SearchAdvancedFiltersContent from '@components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent'; + +import MerchantFilterContentPageWrapper from '@pages/Search/SearchAdvancedFiltersContentPage/MerchantFilterContentPageWrapper'; + +import CONST from '@src/CONST'; +import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm'; +import type {MerchantMatchType} from '@src/types/form/SearchAdvancedFiltersForm'; + +import {NavigationContainer} from '@react-navigation/native'; + +jest.mock('@hooks/useAutoFocusInput', () => () => ({inputCallbackRef: jest.fn()})); +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); + +function renderMerchantFilter({value = 'I', isNegated = false, merchantOperator}: {value?: string; isNegated?: boolean; merchantOperator?: MerchantMatchType} = {}) { + const onChange = jest.fn(); + render( + + + , + ); + return {onChange}; +} + +describe('MerchantFilterContent', () => { + it('keeps the merchant input fixed above the match type options', () => { + // Given a positive Merchant filter + const {onChange} = renderMerchantFilter({merchantOperator: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS}); + const merchantInput = screen.getByLabelText('common.merchant'); + + // When the user edits the value and switches to Equal to + fireEvent.changeText(merchantInput, 'Ibis'); + fireEvent.press(screen.getByText('search.filters.merchant.equalTo')); + + // Then the same input keeps the edited value, so switching the match type does not reset what the user typed + expect(screen.getByLabelText('common.merchant')).toBe(merchantInput); + expect(screen.getByDisplayValue('Ibis')).toBeOnTheScreen(); + + // When the user confirms + fireEvent.press(screen.getByText('common.confirm')); + + // Then the exact operator is submitted with the edited value + expect(onChange).toHaveBeenCalledWith({ + [FILTER_KEYS.MERCHANT]: 'Ibis', + [FILTER_KEYS.MERCHANT_NOT]: undefined, + [FILTER_KEYS.MERCHANT_OPERATOR]: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + }); + }); + + it.each([CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, undefined])('submits Contains when the stored operator is %s', (merchantOperator) => { + // Given a Merchant filter stored as Contains, or with no operator from an older query + const {onChange} = renderMerchantFilter({merchantOperator}); + + // When the user confirms without changing the match type + fireEvent.press(screen.getByText('common.confirm')); + + // Then Contains is submitted, because it is the default Merchant match type + expect(onChange).toHaveBeenCalledWith({ + [FILTER_KEYS.MERCHANT]: 'I', + [FILTER_KEYS.MERCHANT_NOT]: undefined, + [FILTER_KEYS.MERCHANT_OPERATOR]: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, + }); + }); + + it('routes the Merchant filter through the advanced filters content with its stored operator', () => { + // Given the advanced filters page renders a stored exact Merchant filter with an Apply label + const onChange = jest.fn(); + render( + + null, Amount: () => null, Date: () => null, ReportField: () => null, List: () => null}} + onChange={onChange} + /> + , + ); + + // When the user applies the filter + fireEvent.press(screen.getByText('common.apply')); + + // Then the stored exact operator is kept, so the Merchant content received it from the form values + expect(onChange).toHaveBeenCalledWith({ + [FILTER_KEYS.MERCHANT]: 'I', + [FILTER_KEYS.MERCHANT_NOT]: undefined, + [FILTER_KEYS.MERCHANT_OPERATOR]: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + }); + }); + + it('retains the value when changing a stored negative filter back to positive', () => { + // Given a stored negated Merchant filter + const {onChange} = renderMerchantFilter({value: 'Prime', isNegated: true}); + + // When the user switches it to positive and picks Contains + fireEvent.press(screen.getByText('search.filters.filterType.is.positive')); + fireEvent.press(screen.getByText('search.filters.merchant.contains')); + fireEvent.press(screen.getByText('common.confirm')); + + // Then the negated value moves to the positive filter instead of being cleared + expect(onChange).toHaveBeenCalledWith({ + [FILTER_KEYS.MERCHANT]: 'Prime', + [FILTER_KEYS.MERCHANT_NOT]: undefined, + [FILTER_KEYS.MERCHANT_OPERATOR]: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, + }); + }); + + it('hides the match type and submits no operator when the filter is negated', () => { + // Given a positive Merchant filter + const {onChange} = renderMerchantFilter({merchantOperator: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS}); + + // When the user negates it + fireEvent.press(screen.getByText('search.filters.filterType.is.negative')); + + // Then the match type is hidden, because a negated Merchant filter always uses the not-equal operator + expect(screen.queryByText('search.filters.merchant.matchType')).not.toBeOnTheScreen(); + + // When the user confirms + fireEvent.press(screen.getByText('common.confirm')); + + // Then only the negated value is submitted + expect(onChange).toHaveBeenCalledWith({ + [FILTER_KEYS.MERCHANT]: undefined, + [FILTER_KEYS.MERCHANT_NOT]: 'I', + [FILTER_KEYS.MERCHANT_OPERATOR]: undefined, + }); + }); +}); diff --git a/tests/ui/MerchantFilterPopupTest.tsx b/tests/ui/MerchantFilterPopupTest.tsx new file mode 100644 index 000000000000..78f6ef3e97b9 --- /dev/null +++ b/tests/ui/MerchantFilterPopupTest.tsx @@ -0,0 +1,42 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import MerchantFilterPopup from '@components/Search/FilterDropdowns/MerchantFilterPopup'; + +import CONST from '@src/CONST'; +import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm'; + +import {NavigationContainer} from '@react-navigation/native'; + +jest.mock('@hooks/useAutoFocusInput', () => () => ({inputCallbackRef: jest.fn()})); +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); +jest.mock('@hooks/useResponsiveLayout', () => () => ({isSmallScreenWidth: false})); + +describe('MerchantFilterPopup', () => { + it('applies the Merchant filter and closes the popup', () => { + // Given a Merchant chip popup with a stored Contains filter + const closeOverlay = jest.fn(); + const updateFilterForm = jest.fn(); + render( + + + , + ); + + // When the user applies the filter + fireEvent.press(screen.getByText('common.apply')); + + // Then the popup submits the Merchant values and closes, because the chip applies changes immediately + expect(updateFilterForm).toHaveBeenCalledWith({ + [FILTER_KEYS.MERCHANT]: 'I', + [FILTER_KEYS.MERCHANT_NOT]: undefined, + [FILTER_KEYS.MERCHANT_OPERATOR]: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, + }); + expect(closeOverlay).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/ui/SearchColumnsPageTest.tsx b/tests/ui/SearchColumnsPageTest.tsx new file mode 100644 index 000000000000..5c8b96f9eaea --- /dev/null +++ b/tests/ui/SearchColumnsPageTest.tsx @@ -0,0 +1,59 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import type ColumnsSettingsList from '@components/ColumnsSettingsList'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import MockText from '@components/Text'; + +import useOnyx from '@hooks/useOnyx'; + +import Navigation from '@libs/Navigation/Navigation'; +import {buildFilterFormValuesFromQuery, buildSearchQueryJSON} from '@libs/SearchQueryUtils'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SearchColumnsPage from '@src/pages/Search/SearchColumnsPage'; +import SCREENS from '@src/SCREENS'; + +import type {ComponentProps} from 'react'; + +const mockGetRootState = jest.fn(); + +jest.mock('@hooks/useOnyx', () => jest.fn()); +jest.mock('@libs/Navigation/Navigation', () => ({__esModule: true, default: {navigate: jest.fn()}})); +jest.mock('@libs/Navigation/navigationRef', () => ({__esModule: true, default: {getRootState: () => mockGetRootState() as unknown}})); +jest.mock('@components/ColumnsSettingsList', () => ({onSave}: Pick, 'onSave'>) => ( + onSave(['merchant', 'amount'])}>Save columns +)); + +describe('SearchColumnsPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('keeps mixed Merchant predicates when saving different columns', () => { + const input = 'merchant=I merchant*:I'; + const queryJSON = buildSearchQueryJSON(input); + if (!queryJSON) { + throw new Error('Invalid test query'); + } + + const form = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}); + (jest.mocked(useOnyx) as jest.Mock).mockImplementation((key: string) => [key === ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM ? form : undefined]); + mockGetRootState.mockReturnValue({ + routes: [{name: NAVIGATORS.TAB_NAVIGATOR, params: {screen: NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR, params: {screen: SCREENS.SEARCH.ROOT, params: {q: input}}}}], + }); + + render( + + + , + ); + fireEvent.press(screen.getByText('Save columns')); + + const [route] = jest.mocked(Navigation.navigate).mock.calls.at(-1) ?? []; + const updatedQuery = buildSearchQueryJSON(new URL(route ?? '', 'https://example.com').searchParams.get('q') ?? ''); + + expect(updatedQuery?.flatFilters).toEqual(queryJSON.flatFilters); + expect(updatedQuery?.columns).toEqual(['merchant', 'amount']); + }); +}); diff --git a/tests/ui/TextFilterPopupTest.tsx b/tests/ui/TextFilterPopupTest.tsx new file mode 100644 index 000000000000..d1b9872b9ae1 --- /dev/null +++ b/tests/ui/TextFilterPopupTest.tsx @@ -0,0 +1,39 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import TextFilterPopup from '@components/Search/FilterDropdowns/TextFilterPopup'; + +import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm'; + +import {NavigationContainer} from '@react-navigation/native'; + +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); +jest.mock('@hooks/useResponsiveLayout', () => () => ({isSmallScreenWidth: false})); + +describe('TextFilterPopup', () => { + it.each([FILTER_KEYS.DESCRIPTION, FILTER_KEYS.TITLE])('applies the %s text filter without Merchant fields', (baseFilterKey) => { + const closeOverlay = jest.fn(); + const updateFilterForm = jest.fn(); + render( + + + , + ); + + expect(screen.queryByText('search.filters.merchant.matchType')).not.toBeOnTheScreen(); + fireEvent.changeText(screen.getByLabelText('common.description'), 'Supplies'); + expect(screen.queryByText('search.filters.filterType.is.negative')).not.toBeOnTheScreen(); + fireEvent.press(screen.getByText('common.apply')); + + expect(updateFilterForm).toHaveBeenCalledWith({ + [baseFilterKey]: 'Supplies', + [`${baseFilterKey}Not`]: undefined, + }); + expect(closeOverlay).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index 7c054525c2d4..f94025165124 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -56,12 +56,12 @@ import { shouldResetSort, shouldResetSortForViewChange, sortOptionsWithEmptyValue, - withExactMatchFilterKeys, } from '@src/libs/SearchQueryUtils'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; +import type {MerchantMatchType} from '@src/types/form/SearchAdvancedFiltersForm'; import type * as OnyxTypes from '@src/types/onyx'; import type {Connections} from '@src/types/onyx/Policy'; @@ -480,8 +480,8 @@ describe('SearchQueryUtils', () => { }); test('rebuilds a single value containing a comma as one value', () => { - expect(getQueryWithUpdatedValues(String.raw`merchant:Globex\,Ltd`)).toEqual(`${defaultQuery} merchant:"Globex,Ltd"`); - expect(getQueryWithUpdatedValues('merchant:"Globex,Ltd"')).toEqual(`${defaultQuery} merchant:"Globex,Ltd"`); + expect(getQueryWithUpdatedValues(String.raw`merchant:Globex\,Ltd`)).toEqual(`${defaultQuery} merchant*:"Globex,Ltd"`); + expect(getQueryWithUpdatedValues('merchant:"Globex,Ltd"')).toEqual(`${defaultQuery} merchant*:"Globex,Ltd"`); }); test('rebuilds a comma separated list as separate values', () => { @@ -544,7 +544,7 @@ describe('SearchQueryUtils', () => { const result = getQueryWithUpdatedValues(userQuery); - expect(result).toEqual('type:expense sortBy:groupCategory sortOrder:asc view:pie groupBy:category merchant:Amazon'); + expect(result).toEqual('type:expense sortBy:groupCategory sortOrder:asc view:pie groupBy:category merchant*:Amazon'); }); test('deduplicates conflicting type filters keeping the last occurrence', () => { @@ -641,7 +641,44 @@ describe('SearchQueryUtils', () => { const result = buildQueryStringFromFilterFormValues(filterValues); - expect(result).toEqual('type:expense policyID:67890 merchant:Amazon description:Electronics laptop category:electronics,gadgets'); + expect(result).toEqual('type:expense policyID:67890 merchant*:Amazon description:Electronics laptop category:electronics,gadgets'); + }); + + test.each([CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, undefined])('builds a contains merchant query when the merchant operator is %s', (merchantOperator) => { + // Given a merchant filter stored as contains, or with no operator + const filterValues: Partial = {type: 'expense', merchant: 'Amazon', merchantOperator}; + + // When the query string is built + const result = buildQueryStringFromFilterFormValues(filterValues); + + // Then it uses contains, because partial matching is the default merchant behavior + expect(result).toEqual('type:expense merchant*:Amazon'); + }); + + test('preserves multiple merchant clauses when the form value is unchanged', () => { + const queryJSON = buildSearchQueryJSON('type:expense merchant*:Amazon merchant*:Uber'); + const filterValues: Partial = { + type: 'expense', + merchant: 'Uber', + merchantOperator: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, + }; + + const result = buildQueryStringFromFilterFormValues(filterValues, {flatFilters: queryJSON?.flatFilters}); + + expect(result).toEqual('type:expense merchant*:Amazon merchant*:Uber'); + }); + + test('applies a changed merchant operator to every merchant clause', () => { + const queryJSON = buildSearchQueryJSON('type:expense merchant*:Amazon merchant*:Uber'); + const filterValues: Partial = { + type: 'expense', + merchant: 'Uber', + merchantOperator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + }; + + const result = buildQueryStringFromFilterFormValues(filterValues, {flatFilters: queryJSON?.flatFilters}); + + expect(result).toEqual('type:expense merchant=Amazon merchant=Uber'); }); test('currencies and categories', () => { @@ -955,7 +992,7 @@ describe('SearchQueryUtils', () => { const result = buildQueryStringFromFilterFormValues(filterValues, {sortBy: 'amount', sortOrder: 'asc'}); - expect(result).toEqual('sortBy:amount sortOrder:asc type:expense merchant:Amazon limit:25'); + expect(result).toEqual('sortBy:amount sortOrder:asc type:expense merchant*:Amazon limit:25'); }); test('omits limit when not provided', () => { @@ -1372,7 +1409,7 @@ describe('SearchQueryUtils', () => { reportAttributes: undefined, }); - expect(result).toBe('type:expense status:all merchant:Uber'); + expect(result).toBe('type:expense status:all merchant*:Uber'); }); test('maps workspace names and maintains manual order', () => { @@ -1409,7 +1446,7 @@ describe('SearchQueryUtils', () => { reportAttributes: undefined, }); - expect(result).toBe('workspace:"Team Space" type:expense merchant:Starbucks'); + expect(result).toBe('workspace:"Team Space" type:expense merchant*:Starbucks'); }); test('rawQuery overrides canonical filter values when provided', () => { @@ -1532,6 +1569,24 @@ describe('SearchQueryUtils', () => { }); describe('buildFilterFormValuesFromQuery', () => { + test.each([ + ['merchant=Uber', CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO], + ['merchant:Uber', CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS], + ['merchant*:Uber', CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS], + ])('restores the merchant operator from %s', (merchantQuery, merchantOperator) => { + // Given a query with an explicit exact, legacy, or contains merchant filter + const queryJSON = buildSearchQueryJSON(`sortBy:date sortOrder:desc type:expense ${merchantQuery}`); + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + + // When the form values are built from the query + const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}); + + // Then only `=` restores Equal to, because legacy `:` keeps its partial-match behavior + expect(result).toEqual({type: 'expense', merchant: 'Uber', merchantOperator}); + }); + test('category filter includes empty values', () => { const policyID = generatePolicyID(); const queryString = 'sortBy:date sortOrder:desc type:expense category:none,Uncategorized,Maintenance'; @@ -2736,7 +2791,8 @@ describe('SearchQueryUtils', () => { // Captured before the footer keys existed. These hashes key saved searches and snapshots, so they must not drift. expect(buildSearchQueryJSON('type:expense')?.hash).toEqual(959171759); expect(buildSearchQueryJSON('type:expense-report')?.hash).toEqual(1657503970); - expect(buildSearchQueryJSON('type:expense merchant:Amazon')?.hash).toEqual(1692688529); + // `merchant:` parses as contains and hashes as `merchant*:`, so this hash differs from the one before the match type existed. + expect(buildSearchQueryJSON('type:expense merchant:Amazon')?.hash).toEqual(1712664595); }); }); @@ -3032,6 +3088,16 @@ describe('SearchQueryUtils', () => { }); }); + describe('contains filter parsing', () => { + it('treats negated contains as a keyword', () => { + const queryJSON = buildSearchQueryJSON('type:expense -merchant*:Uber'); + const keywordFilter = queryJSON?.flatFilters.find((filter) => filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD); + + expect(keywordFilter?.filters.some((filter) => filter.value === '-merchant*:Uber')).toBe(true); + expect(queryJSON?.flatFilters.some((filter) => filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT)).toBe(false); + }); + }); + describe('getFilterDisplayValue', () => { const mockCardList = {}; const mockCardFeeds = {}; @@ -3318,7 +3384,7 @@ describe('SearchQueryUtils', () => { const result = buildSearchQueryString(queryJSON); expect(result).toContain('view:pie'); - expect(result).toContain('merchant:Amazon'); + expect(result).toContain('merchant*:Amazon'); }); test('serializes the compare root key', () => { @@ -3392,12 +3458,12 @@ describe('SearchQueryUtils', () => { expect(keywordFilter?.filters.at(0)?.value).toBe(keyword); }); - test('does not add quotes to non-keyword filter values', () => { - const queryJSON = buildSearchQueryJSON('type:expense merchant:Amazon'); + test('uses the explicit exact operator for exact merchant filters', () => { + const queryJSON = buildSearchQueryJSON('type:expense merchant=Amazon'); const result = buildSearchQueryString(queryJSON); - expect(result).toContain('merchant:Amazon'); + expect(result).toContain('merchant=Amazon'); expect(result).not.toContain('"Amazon"'); }); }); @@ -3460,6 +3526,217 @@ describe('SearchQueryUtils', () => { }); describe('buildFilterQueryWithSortDefaults', () => { + describe('Merchant predicates', () => { + function updateQuery(input: string, updates: Partial) { + const originalQuery = buildSearchQueryJSON(input); + if (!originalQuery) { + throw new Error('Invalid test query'); + } + + const form = buildFilterFormValuesFromQuery(originalQuery, {}, {}, {}, {}, {}, {}); + const result = buildFilterQueryWithSortDefaults({...form, ...updates}, originalQuery, originalQuery); + const updatedQuery = buildSearchQueryJSON(result ?? ''); + if (!updatedQuery) { + throw new Error('Invalid updated query'); + } + + return {originalQuery, updatedQuery}; + } + + test.each([ + // Mixed operators, both orders + 'merchant="Coffee Shop" merchant*:Coffee', + 'merchant*:Coffee merchant="Coffee Shop"', + // Mixed operators with lists and quoted commas + 'merchant="Coffee Shop",Uber merchant*:Coffee,Tea', + 'merchant="Coffee, Shop" merchant*:"Coffee, Shop"', + // Three clauses, and positive mixed with negated + 'merchant=I merchant*:I merchant*:g', + 'merchant=I merchant*:I -merchant:Ig', + // Same operator repeated + 'merchant*:Coffee merchant*:Shop', + // Single clause of each kind + 'merchant=I', + 'merchant*:I', + 'merchant=Amazon,Uber', + // Negated + '-merchant:Amazon -merchant:Prime', + '-merchant:"Coffee, Shop",Uber', + ])('preserves Merchant predicates when changing Currency: %s', (input) => { + // Given a query with Merchant predicates + // When only the Currency filter changes + const {originalQuery, updatedQuery} = updateQuery(input, {currency: ['USD']}); + + // Then every Merchant predicate is kept as it was and the Currency filter is added + expect(updatedQuery.flatFilters.filter((filter) => filter.key === 'merchant')).toEqual(originalQuery.flatFilters.filter((filter) => filter.key === 'merchant')); + expect(updatedQuery.flatFilters).toContainEqual({key: 'currency', filters: [{operator: 'eq', value: 'USD'}]}); + }); + + test.each>([{description: 'Lunch'}, {dateAfter: '2026-01-01'}, {groupBy: CONST.SEARCH.GROUP_BY.MONTH, view: CONST.SEARCH.VIEW.BAR}])( + 'preserves Merchant predicates when changing other filters: %j', + (updates) => { + const {originalQuery, updatedQuery} = updateQuery('merchant=I merchant*:I', updates); + + expect(updatedQuery.flatFilters.filter((filter) => filter.key === 'merchant')).toEqual(originalQuery.flatFilters); + }, + ); + + test('preserves Merchant predicates through consecutive form updates', () => { + const firstUpdate = updateQuery('merchant=I merchant*:I', {description: 'Lunch'}); + const secondUpdate = updateQuery(buildSearchQueryString(firstUpdate.updatedQuery), {currency: ['USD']}); + + expect(secondUpdate.updatedQuery.flatFilters.filter((filter) => filter.key === 'merchant')).toEqual(firstUpdate.originalQuery.flatFilters); + expect(secondUpdate.updatedQuery.flatFilters).toContainEqual({key: 'description', filters: [{operator: 'eq', value: 'Lunch'}]}); + }); + + test.each<{input: string; operator: MerchantMatchType; expectedValue: string | string[]}>([ + {input: 'merchant*:I,Ig', operator: 'eq', expectedValue: ['I', 'Ig']}, + {input: 'merchant=I,Ig', operator: 'contains', expectedValue: ['I', 'Ig']}, + {input: 'merchant:I,Ig', operator: 'eq', expectedValue: ['I', 'Ig']}, + {input: 'merchant*:"I,Ig"', operator: 'eq', expectedValue: 'I,Ig'}, + {input: 'merchant="I,Ig"', operator: 'contains', expectedValue: 'I,Ig'}, + {input: 'merchant*:"Coffee, Shop",Uber', operator: 'eq', expectedValue: ['Coffee, Shop', 'Uber']}, + {input: 'merchant="Coffee, Shop",Uber', operator: 'contains', expectedValue: ['Coffee, Shop', 'Uber']}, + ])('preserves Merchant values when changing only the match type: %j', ({input, operator, expectedValue}) => { + const {updatedQuery} = updateQuery(input, {merchantOperator: operator}); + + expect(updatedQuery.filters).toEqual({operator, left: 'merchant', right: expectedValue}); + }); + + test.each<{input: string; operator: MerchantMatchType}>([ + {input: 'merchant=I merchant*:I,Ig', operator: 'eq'}, + {input: 'merchant*:I merchant=I,Ig', operator: 'contains'}, + ])('applies a changed match type to every Merchant predicate: %j', ({input, operator}) => { + const {updatedQuery} = updateQuery(input, {merchantOperator: operator}); + + expect(updatedQuery.flatFilters.filter((filter) => filter.key === 'merchant')).toEqual([ + {key: 'merchant', filters: [{operator, value: 'I'}]}, + { + key: 'merchant', + filters: [ + {operator, value: 'I'}, + {operator, value: 'Ig'}, + ], + }, + ]); + }); + + test.each([ + 'merchant:Amazon merchant:Prime', + 'merchant=Amazon merchant=Prime', + 'merchant=Amazon merchant*:Prime', + 'merchant:Amazon,Prime', + 'merchant:"Coffee, Shop",Uber merchant:Prime', + ])('applies negation to every original Merchant value: %s', (input) => { + const originalQuery = buildSearchQueryJSON(input); + if (!originalQuery) { + throw new Error('Invalid test query'); + } + const form = buildFilterFormValuesFromQuery(originalQuery, {}, {}, {}, {}, {}, {}); + const {updatedQuery} = updateQuery(input, { + merchant: undefined, + merchantNot: form.merchantNot ?? form.merchant, + merchantOperator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + }); + + expect(updatedQuery.flatFilters).toEqual( + originalQuery?.flatFilters.map((filter) => ({ + ...filter, + filters: filter.filters.map((item) => ({...item, operator: 'neq'})), + })), + ); + }); + + test.each(['-merchant:Amazon -merchant:Prime', '-merchant:Amazon,Prime', '-merchant:"Coffee, Shop",Uber -merchant:Prime', 'merchant:Amazon -merchant:Prime'])( + 'applies positive matching to every original Merchant value: %s', + (input) => { + const originalQuery = buildSearchQueryJSON(input); + if (!originalQuery) { + throw new Error('Invalid test query'); + } + const form = buildFilterFormValuesFromQuery(originalQuery, {}, {}, {}, {}, {}, {}); + const {updatedQuery} = updateQuery(input, { + merchant: form.merchantNot, + merchantNot: undefined, + merchantOperator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + }); + + expect(updatedQuery.flatFilters).toEqual( + originalQuery?.flatFilters.map((filter) => ({ + ...filter, + filters: filter.filters.map((item) => ({...item, operator: 'eq'})), + })), + ); + }, + ); + + test.each(['merchant:Amazon merchant:Prime', '-merchant:Amazon -merchant:Prime', 'merchant=Amazon -merchant:Prime'])( + 'removes every Merchant predicate without changing other filters: %s', + (input) => { + const {updatedQuery} = updateQuery(`type:expense description:Lunch ${input}`, {merchant: undefined, merchantNot: undefined}); + + expect(updatedQuery.type).toBe('expense'); + expect(updatedQuery.flatFilters).toEqual([{key: 'description', filters: [{operator: 'eq', value: 'Lunch'}]}]); + }, + ); + + test('preserves both Merchant values through negation and match type round trips', () => { + const negative = updateQuery('merchant:Amazon merchant:Prime', {merchant: undefined, merchantNot: 'Prime', merchantOperator: 'eq'}); + const positive = updateQuery(buildSearchQueryString(negative.updatedQuery), {merchant: 'Prime', merchantNot: undefined, merchantOperator: 'eq'}); + const contains = updateQuery(buildSearchQueryString(positive.updatedQuery), {merchantOperator: 'contains'}); + + expect(contains.updatedQuery.filters).toEqual(negative.originalQuery.filters); + }); + + test('keeps a Merchant list through successive match type and Currency changes without mutating the original query', () => { + const {originalQuery, updatedQuery} = updateQuery('merchant*:I,Ig', {merchantOperator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO}); + const currencyUpdate = updateQuery(buildSearchQueryString(updatedQuery), {currency: ['USD']}); + + expect(originalQuery.filters).toEqual({operator: 'contains', left: 'merchant', right: ['I', 'Ig']}); + expect(currencyUpdate.updatedQuery.flatFilters).toContainEqual({ + key: 'merchant', + filters: [ + {operator: 'eq', value: 'I'}, + {operator: 'eq', value: 'Ig'}, + ], + }); + expect(currencyUpdate.updatedQuery.flatFilters).toContainEqual({key: 'currency', filters: [{operator: 'eq', value: 'USD'}]}); + }); + + test.each<{updates: Partial; expected: string}>([ + {updates: {merchant: 'Tea'}, expected: 'merchant*:Tea'}, + {updates: {merchant: 'I,Ig', merchantOperator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO}, expected: 'merchant="I,Ig"'}, + {updates: {merchant: undefined}, expected: ''}, + ])('replaces or removes a Merchant list on an explicit change: %j', ({updates, expected}) => { + const {updatedQuery} = updateQuery('merchant*:Amazon,Uber', updates); + + expect(updatedQuery.flatFilters.filter((filter) => filter.key === 'merchant')).toEqual(buildSearchQueryJSON(expected)?.flatFilters); + }); + + test.each<{updates: Partial; expected: string}>([ + {updates: {merchant: 'Tea'}, expected: 'merchant*:Tea'}, + {updates: {merchantOperator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO}, expected: 'merchant=I'}, + {updates: {merchant: undefined}, expected: ''}, + {updates: {merchant: undefined, merchantNot: 'I'}, expected: '-merchant:I'}, + {updates: {type: CONST.SEARCH.DATA_TYPES.CHAT}, expected: ''}, + ])('replaces or removes Merchant predicates on an explicit change: %j', ({updates, expected}) => { + const {updatedQuery} = updateQuery('merchant=I merchant*:I', updates); + + expect(updatedQuery.flatFilters.filter((filter) => filter.key === 'merchant')).toEqual(buildSearchQueryJSON(expected)?.flatFilters); + }); + + test('clears all Merchant predicates when resetting advanced filters', () => { + const queryJSON = buildSearchQueryJSON('merchant=I merchant*:I'); + if (!queryJSON) { + throw new Error('Invalid test query'); + } + + const updatedQuery = buildSearchQueryJSON(buildQueryStringWithResetFilters(queryJSON, undefined)); + + expect(updatedQuery?.flatFilters).toEqual([]); + }); + }); + test('groupBy change replaces stale sortBy with new default', () => { const result = buildFilterQueryWithSortDefaults( {type: 'expense', groupBy: CONST.SEARCH.GROUP_BY.MONTH, view: CONST.SEARCH.VIEW.BAR}, @@ -4435,19 +4712,109 @@ describe('SearchQueryUtils', () => { }); }); - describe('applyContainsOperatorToTextFields', () => { - it('should transform merchant eq to contains', () => { - const queryJSON = buildSearchQueryJSON('type:expense merchant:coffee'); + describe('merchant multi-value query serialization', () => { + test.each(['merchant*:Amazon,Uber', 'merchant:Amazon,Uber'])('preserves array AST for %s', (input) => { + const queryJSON = buildSearchQueryJSON(input); if (!queryJSON?.filters) { - throw new Error('Expected filters to be defined'); + throw new Error('Expected merchant filters to be defined'); } - const transformed = applyContainsOperatorToTextFields(queryJSON.filters); - const merchantNode = findNode(transformed, 'merchant'); + + const merchantNode = findNode(queryJSON.filters, CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT); if (!merchantNode) { throw new Error('Expected merchant node to be found in AST'); } + expect(merchantNode.operator).toBe(CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS); - expect(merchantNode.right).toBe('coffee'); + expect(merchantNode.right).toEqual(['Amazon', 'Uber']); + + const builtQuery = buildSearchQueryString(queryJSON); + const updatedQuery = getQueryWithUpdatedValues(input, true); + if (!updatedQuery) { + throw new Error('Expected updated query to be defined'); + } + + expect(updatedQuery).toBe(builtQuery); + expect(buildSearchQueryJSON(builtQuery)?.filters).toEqual(queryJSON.filters); + expect(buildSearchQueryJSON(updatedQuery)?.filters).toEqual(queryJSON.filters); + + const canonicalQueryJSON = buildSearchQueryJSON(updatedQuery); + if (!canonicalQueryJSON?.filters) { + throw new Error('Expected canonical merchant filters to be defined'); + } + + expect(serializeQueryJSONForBackend(canonicalQueryJSON)).toBe(JSON.stringify(canonicalQueryJSON)); + expect(getQueryWithUpdatedValues(updatedQuery, true)).toBe(updatedQuery); + expect(buildSearchQueryString(buildSearchQueryJSON(updatedQuery))).toBe(updatedQuery); + }); + + test.each([ + ['merchant=Amazon,Uber', CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, ['Amazon', 'Uber'], 'merchant=Amazon,Uber'], + ['-merchant:Amazon,Uber', CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO, ['Amazon', 'Uber'], '-merchant:Amazon,Uber'], + ['merchant*:Amazon', CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, 'Amazon', 'merchant*:Amazon'], + ['merchant*:"Amazon,Uber"', CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, 'Amazon,Uber', 'merchant*:"Amazon,Uber"'], + ])('keeps merchant operator semantics for %s', (input, operator, value, expectedQuery) => { + const queryJSON = buildSearchQueryJSON(input); + if (!queryJSON?.filters) { + throw new Error('Expected merchant filters to be defined'); + } + + const merchantNode = findNode(queryJSON.filters, CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT); + if (!merchantNode) { + throw new Error('Expected merchant node to be found in AST'); + } + + expect(merchantNode.operator).toBe(operator); + expect(merchantNode.right).toEqual(value); + const rebuiltQueryString = buildSearchQueryString(queryJSON); + expect(rebuiltQueryString).toContain(expectedQuery); + expect(buildSearchQueryJSON(rebuiltQueryString)?.filters).toEqual(queryJSON.filters); + }); + + test('preserves separate merchant contains filters when rebuilding', () => { + const queryJSON = buildSearchQueryJSON('merchant*:Amazon merchant*:Uber'); + if (!queryJSON?.filters) { + throw new Error('Expected merchant query to be defined'); + } + + expect(queryJSON.filters.operator).toBe(CONST.SEARCH.SYNTAX_OPERATORS.AND); + expect(buildSearchQueryJSON(buildSearchQueryString(queryJSON))?.filters).toEqual(queryJSON.filters); + }); + + test('keeps category comma lists on the exact operator', () => { + const queryJSON = buildSearchQueryJSON('category:Travel,Meals'); + if (!queryJSON?.filters) { + throw new Error('Expected category filters to be defined'); + } + + const categoryNode = findNode(queryJSON.filters, CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY); + if (!categoryNode) { + throw new Error('Expected category node to be found in AST'); + } + + expect(categoryNode.operator).toBe(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO); + expect(categoryNode.right).toEqual(['Travel', 'Meals']); + expect(buildSearchQueryString(queryJSON)).toContain('category:Travel,Meals'); + }); + }); + + describe('applyContainsOperatorToTextFields', () => { + it.each([ + ['merchant=coffee', CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO], + ['merchant:coffee', CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS], + ['merchant*:coffee', CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS], + ])('should preserve the parsed merchant operator for %s', (merchantQuery, merchantOperator) => { + // Given a query with an explicit exact, legacy, or contains merchant filter + const queryJSON = buildSearchQueryJSON(`type:expense ${merchantQuery}`); + if (!queryJSON?.filters) { + throw new Error('Expected filters to be defined'); + } + + // When text field operators are normalized + const merchantNode = findNode(applyContainsOperatorToTextFields(queryJSON.filters), 'merchant'); + + // Then the parsed merchant operator is kept, because the parser already maps legacy `:` to contains + expect(merchantNode?.operator).toBe(merchantOperator); + expect(merchantNode?.right).toBe('coffee'); }); it('should transform description eq to contains', () => { @@ -4489,7 +4856,7 @@ describe('SearchQueryUtils', () => { expect(merchantNode.operator).toBe(CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO); }); - it('should transform merchant but not category in a compound query', () => { + it('should preserve legacy merchant contains and category exact in a compound query', () => { const queryJSON = buildSearchQueryJSON('type:expense merchant:coffee category:travel'); if (!queryJSON?.filters) { throw new Error('Expected filters to be defined'); @@ -4511,62 +4878,57 @@ describe('SearchQueryUtils', () => { }); describe('serializeQueryJSONForBackend', () => { - it('should apply contains to merchant in AST filters', () => { - const queryJSON = buildSearchQueryJSON('type:expense merchant:coffee'); + it.each([ + ['merchant:coffee', CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS], + ['merchant*:coffee', CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS], + ['merchant=coffee', CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO], + ])('should preserve the merchant operator in AST filters for %s', (merchantQuery, merchantOperator) => { + // Given a query with a legacy, contains, or explicit exact merchant filter + const queryJSON = buildSearchQueryJSON(`type:expense ${merchantQuery}`); if (!queryJSON) { throw new Error('Expected queryJSON to be defined'); } - const normalizedFilters = applyContainsOperatorToTextFields(queryJSON.filters); - expect(serializeQueryJSONForBackend(queryJSON)).toBe(JSON.stringify({...queryJSON, filters: normalizedFilters})); - const merchantNode = findNode(normalizedFilters, 'merchant'); - if (!merchantNode) { - throw new Error('Expected merchant node to be found in AST'); - } - expect(merchantNode.operator).toBe(CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS); + + // When the query is serialized for the backend + const result = serializeQueryJSONForBackend(queryJSON); + + // Then the merchant filter is sent unchanged with its parsed operator + expect(result).toBe(JSON.stringify(queryJSON)); + expect(findNode(queryJSON.filters, 'merchant')?.operator).toBe(merchantOperator); + }); + + it.each([CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS])('should preserve the %s merchant operator in rawFilterList', (operator) => { + // Given a raw merchant filter with an exact or contains operator + const rawFilterList = [{key: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, operator, value: 'coffee'}]; + + // When the query is serialized for the backend + const result = serializeQueryJSONForBackend({filters: undefined, rawFilterList}); + + // Then the merchant operator is not rewritten + expect(result).toBe(JSON.stringify({filters: undefined, rawFilterList})); }); - it('should apply contains to merchant in rawFilterList', () => { - const rawFilterList = [{key: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: 'coffee'}]; + it('should apply contains to description in rawFilterList', () => { + const rawFilterList = [{key: CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION, operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: 'coffee'}]; const normalizedRawFilterList = rawFilterList.map((filter) => ({...filter, operator: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS})); expect(serializeQueryJSONForBackend({filters: undefined, rawFilterList})).toBe(JSON.stringify({filters: undefined, rawFilterList: normalizedRawFilterList})); }); - it('should preserve exact merchant matches in AST filters', () => { - const queryJSON = buildSearchQueryJSON('type:expense merchant:coffee'); + it('should keep merchant eq and turn description eq into contains', () => { + // Given a query with an exact merchant list and a description filter, both parsed as `eq` + const queryJSON = buildSearchQueryJSON('type:expense merchant=Amazon,"Amazon Marketplace" description:order'); if (!queryJSON) { throw new Error('Expected queryJSON to be defined'); } - const exactMatchFilterKeys = new Set([CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]); - expect(serializeQueryJSONForBackend(queryJSON, exactMatchFilterKeys)).toBe(JSON.stringify(queryJSON)); - const merchantNode = findNode(queryJSON.filters, 'merchant'); - if (!merchantNode) { - throw new Error('Expected merchant node to be found in AST'); - } - expect(merchantNode.operator).toBe(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO); - }); - it('should preserve exact merchant matches in rawFilterList', () => { - const rawFilterList = [{key: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: 'coffee'}]; - const exactMatchFilterKeys = new Set([CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]); - expect(serializeQueryJSONForBackend({filters: undefined, rawFilterList}, exactMatchFilterKeys)).toBe(JSON.stringify({filters: undefined, rawFilterList})); - }); + // When the query is serialized for the backend + const result = serializeQueryJSONForBackend(queryJSON); - it('should preserve multiple exact merchant matches while keeping description as contains', () => { - const queryJSON = buildSearchQueryJSON('type:expense merchant:Amazon,"Amazon Marketplace" description:order'); - if (!queryJSON) { - throw new Error('Expected queryJSON to be defined'); - } - const exactMatchFilterKeys = new Set([CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]); - const normalizedFilters = applyContainsOperatorToTextFields(queryJSON.filters, exactMatchFilterKeys); - expect(serializeQueryJSONForBackend(queryJSON, exactMatchFilterKeys)).toBe(JSON.stringify({...queryJSON, filters: normalizedFilters})); - const merchantNode = findNode(normalizedFilters, 'merchant'); - const descriptionNode = findNode(normalizedFilters, 'description'); - if (!merchantNode || !descriptionNode) { - throw new Error('Expected merchant and description nodes to be found in AST'); - } - expect(merchantNode.operator).toBe(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO); - expect(merchantNode.right).toEqual(['Amazon', 'Amazon Marketplace']); - expect(descriptionNode.operator).toBe(CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS); + // Then merchant keeps exact matching, because the parser already tells `merchant=` from `merchant:`, + // and description still uses partial matching on the backend + expect(result).toContain('"operator":"eq","left":"merchant","right":["Amazon","Amazon Marketplace"]'); + expect(result).toContain('"operator":"contains","left":"description","right":"order"'); + expect(result).not.toContain('"operator":"contains","left":"merchant"'); }); it('should not affect non-text fields in rawFilterList', () => { @@ -4575,22 +4937,6 @@ describe('SearchQueryUtils', () => { }); }); - describe('withExactMatchFilterKeys', () => { - it('should give exact queries distinct snapshot hashes', () => { - const partialQuery = buildSearchQueryJSON('type:expense merchant:Amazon'); - if (!partialQuery) { - throw new Error('Expected partial query to be defined'); - } - - const exactQuery = withExactMatchFilterKeys(partialQuery, [CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]); - - expect(exactQuery.hash).not.toBe(partialQuery.hash); - expect(exactQuery.recentSearchHash).not.toBe(partialQuery.recentSearchHash); - expect(exactQuery.similarSearchHash).not.toBe(partialQuery.similarSearchHash); - expect(exactQuery.exactMatchFilterKeys).toEqual([CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]); - }); - }); - describe('getKeywordQueryWithCurrentSearchContext', () => { it('should prepend current search context to keyword-only input', () => { const currentQueryJSON = buildSearchQueryJSON('type:trip status:outstanding'); @@ -4634,6 +4980,14 @@ describe('SearchQueryUtils', () => { expect(result).toContain('type:trip'); }); + it('should escape input that uses the contains operator so it is treated as a keyword', () => { + const currentQueryJSON = buildSearchQueryJSON('type:trip status:all'); + + const result = currentQueryJSON ? getKeywordQueryWithCurrentSearchContext('merchant*:Uber', currentQueryJSON) : ''; + expect(result).toContain('"merchant*:Uber"'); + expect(result).toContain('type:trip'); + }); + it('should stop escaping after the unquoted filter value', () => { const currentQueryJSON = buildSearchQueryJSON('type:trip status:all'); diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 605892308cc2..37702cbe0a5f 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -18,7 +18,7 @@ import type { } from '@components/Search/SearchList/ListItem/types'; import {GROUP_ITEM_TYPES} from '@components/Search/SearchList/ListItem/types'; import {getExpenseHeaders} from '@components/Search/SearchTableHeader'; -import type {SearchColumnType, SearchFilterKey, SelectedTransactionInfo, SortOrder} from '@components/Search/types'; +import type {ASTNode, SearchColumnType, SearchFilterKey, SearchQueryJSON, SelectedTransactionInfo, SortOrder} from '@components/Search/types'; import type {ListItem} from '@components/SelectionList/types'; import Navigation from '@navigation/Navigation'; @@ -5311,7 +5311,7 @@ describe('SearchUIUtils', () => { expect(emptyMerchantItem?.transactionsQueryJSON).toBeDefined(); // The query should use 'none' (MERCHANT_EMPTY_VALUE) instead of empty string expect(emptyMerchantItem?.transactionsQueryJSON?.inputQuery).toContain(CONST.SEARCH.MERCHANT_EMPTY_VALUE); - expect(emptyMerchantItem?.transactionsQueryJSON?.exactMatchFilterKeys).toEqual([CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT]); + expect(emptyMerchantItem?.transactionsQueryJSON?.inputQuery).toContain(`merchant=${CONST.SEARCH.MERCHANT_EMPTY_VALUE}`); }); it('should treat DEFAULT_MERCHANT "Expense" as empty merchant and display "No merchant"', () => { @@ -8183,6 +8183,96 @@ describe('SearchUIUtils', () => { }); }); + describe('Merchant group exact match', () => { + function findFilterNode(node: ASTNode | null | undefined, key: string): ASTNode | undefined { + if (!node) { + return undefined; + } + if (node.left === key) { + return node; + } + const leftMatch = typeof node.left === 'object' ? findFilterNode(node.left, key) : undefined; + if (leftMatch) { + return leftMatch; + } + return typeof node.right === 'object' && !Array.isArray(node.right) ? findFilterNode(node.right, key) : undefined; + } + + function getMerchantNode(queryJSON: SearchQueryJSON): ASTNode | undefined { + return findFilterNode(queryJSON.filters, CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT); + } + + function getMerchantDrillDownQuery(merchant: string): SearchQueryJSON | undefined { + const [result] = getSectionsByType( + SearchUIUtils.getSections({ + dateFnsLocale: undefined, + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + data: { + personalDetailsList: {}, + [`${CONST.SEARCH.GROUP_PREFIX}777777777` as const]: {merchant, count: 1, currency: 'USD', total: 10}, + }, + currentAccountID: 2074551, + currentUserEmail: '', + translate: translateLocal, + formatPhoneNumber, + bankAccountList: {}, + rules: undefined, + groupBy: CONST.SEARCH.GROUP_BY.MERCHANT, + queryJSON: buildSearchQueryJSON('type:expense groupBy:merchant'), + conciergeReportID: undefined, + convertToDisplayString, + reportAttributesDerivedValue: {}, + }), + SearchUIUtils.isTransactionMerchantGroupListItemType, + ); + return result.at(0)?.transactionsQueryJSON; + } + + it('should send an exact merchant match when drilling down into a merchant group', () => { + // Given a merchant group "Amazon", where a contains match would also load "Amazon Marketplace" expenses + // When the sections build the drill-down query for that group + const drillDownQueryJSON = getMerchantDrillDownQuery('Amazon'); + if (!drillDownQueryJSON) { + throw new Error('Expected the merchant drill-down query to be defined'); + } + + // Then the query uses the exact `merchant=` syntax and the backend receives it unchanged as `eq`, + // so only the group's own expenses load + expect(drillDownQueryJSON.inputQuery).toContain('merchant=Amazon'); + expect(SearchQueryUtils.serializeQueryJSONForBackend(drillDownQueryJSON)).toBe(JSON.stringify(drillDownQueryJSON)); + const merchantNode = getMerchantNode(drillDownQueryJSON); + expect(merchantNode?.operator).toBe(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO); + expect(merchantNode?.right).toBe('Amazon'); + }); + + it.each(['merchant:Amazon', 'merchant*:Amazon'])('should give the merchant drill-down query a different hash than the same search with %s', (containsFilter) => { + // Given the drill-down query of the "Amazon" group and the same search with a contains merchant filter + const drillDownQueryJSON = getMerchantDrillDownQuery('Amazon'); + const containsQueryJSON = buildSearchQueryJSON(drillDownQueryJSON?.inputQuery.replace('merchant=Amazon', containsFilter) ?? ''); + + // When the two queries are compared + // Then their hashes differ, because `merchant=` already makes the query string differ, so the snapshots never share results + expect(containsQueryJSON?.inputQuery).toContain(containsFilter); + expect(drillDownQueryJSON?.hash).not.toBe(containsQueryJSON?.hash); + expect(drillDownQueryJSON?.recentSearchHash).not.toBe(containsQueryJSON?.recentSearchHash); + }); + + it('should give each selected merchant group a merchant filter entry with its own value', () => { + // Given the "Amazon" group and the "No merchant" group of a merchant-grouped search + const groups = [{merchant: 'Amazon'}, {merchant: ''}]; + + // When the export builds the filter entry for each selected group + const selectedGroupEntries = groups.map((group) => SearchUIUtils.getSelectedGroupFilterEntry(CONST.SEARCH.GROUP_BY.MERCHANT, group)); + + // Then each entry filters on merchant with the group's own value, and the empty merchant maps to `none`, + // so the export can match each group exactly. The hook test checks that the backend gets `eq`. + expect(selectedGroupEntries).toEqual([ + {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, value: 'Amazon'}, + {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, value: CONST.SEARCH.MERCHANT_EMPTY_VALUE}, + ]); + }); + }); + describe('Test getSortedSections', () => { it('should return getSortedReportActionData result when type is CHAT', () => { const sortedActions = getSortedSectionsByType( @@ -14672,6 +14762,21 @@ describe('SearchUIUtils', () => { expect(result).toBe(translateLocal('common.read')); }); + + test.each([ + [CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, 'search.filters.merchant.equalTo'], + [CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, 'search.filters.merchant.contains'], + [undefined, 'search.filters.merchant.contains'], + ] as const)('includes the %s match type in a Merchant filter label', (merchantOperator, translationKey) => { + // Given a Merchant filter with an exact, contains, or missing operator + const form = {[FILTER_KEYS.MERCHANT]: 'I', [FILTER_KEYS.MERCHANT_OPERATOR]: merchantOperator}; + + // When the filter label is built + const result = SearchUIUtils.getDisplayValue(FILTER_KEYS.MERCHANT, form, CONST.SEARCH.DATA_TYPES.EXPENSE, translateLocal, localeCompare); + + // Then the match type is part of the translated label, so a missing operator reads as contains + expect(result).toBe(translateLocal(translationKey, 'I')); + }); }); describe('filterValidHasValues', () => { @@ -15280,6 +15385,14 @@ describe('hasFilterContentValuesChanged', () => { // A text content offers no options, so the search type is nothing to it. ignored: [{[SYNTAX_FILTER_KEYS.MERCHANT]: 'a'}, {[SYNTAX_FILTER_KEYS.MERCHANT]: 'a', type: CONST.SEARCH.DATA_TYPES.INVOICE}], }, + { + kind: 'merchant, reading its match type', + filterKey: SYNTAX_FILTER_KEYS.MERCHANT, + read: [ + {[SYNTAX_FILTER_KEYS.MERCHANT]: 'a', [FILTER_KEYS.MERCHANT_OPERATOR]: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS}, + {[SYNTAX_FILTER_KEYS.MERCHANT]: 'a', [FILTER_KEYS.MERCHANT_OPERATOR]: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO}, + ], + }, { kind: 'amount', filterKey: SYNTAX_FILTER_KEYS.AMOUNT, diff --git a/tests/unit/Search/chartGroupByConfigTest.ts b/tests/unit/Search/chartGroupByConfigTest.ts new file mode 100644 index 000000000000..26a5be3d6dfe --- /dev/null +++ b/tests/unit/Search/chartGroupByConfigTest.ts @@ -0,0 +1,14 @@ +import CHART_GROUP_BY_CONFIG from '@components/Search/chartGroupByConfig'; +import type {TransactionMerchantGroupListItemType} from '@components/Search/SearchList/ListItem/types'; + +import CONST from '@src/CONST'; + +import createMock from '../../utils/createMock'; + +describe('chartGroupByConfig', () => { + it('uses the explicit exact operator for merchant drill-downs', () => { + const item = createMock({merchant: 'I'}); + + expect(CHART_GROUP_BY_CONFIG[CONST.SEARCH.GROUP_BY.MERCHANT].getFilterQuery(item)).toBe('merchant="I"'); + }); +}); diff --git a/tests/unit/SearchAutocompleteParserTest.ts b/tests/unit/SearchAutocompleteParserTest.ts index 2d3a6b8bae56..3346fad514c9 100644 --- a/tests/unit/SearchAutocompleteParserTest.ts +++ b/tests/unit/SearchAutocompleteParserTest.ts @@ -342,6 +342,21 @@ const tests: Array<{query: string; expected: ExpectedAutocompleteParserResult}> ], }, }, + { + query: 'type:expense -merchant*:Uber', + expected: { + autocomplete: null, + ranges: [{key: 'type', value: 'expense', negated: false, start: 5, length: 7}], + }, + }, + { + // The "contains" operator is only supported for the merchant field + query: 'type:expense category*:travel', + expected: { + autocomplete: null, + ranges: [{key: 'type', value: 'expense', negated: false, start: 5, length: 7}], + }, + }, ]; const limitAutocompleteTests: Array<{query: string; expected: ExpectedAutocompleteParserResult; description: string}> = [ diff --git a/tests/unit/SearchParserTest.ts b/tests/unit/SearchParserTest.ts index 6602508e4cdd..96b4cdc35431 100644 --- a/tests/unit/SearchParserTest.ts +++ b/tests/unit/SearchParserTest.ts @@ -231,7 +231,7 @@ const tests = [ }, }, right: { - operator: 'eq', + operator: 'contains', left: 'merchant', right: 'marriott', }, @@ -261,7 +261,7 @@ const tests = [ }, }, right: { - operator: 'eq', + operator: 'contains', left: 'merchant', right: "McDonald's", }, @@ -596,7 +596,7 @@ const tests = [ }, }, right: { - operator: 'eq', + operator: 'contains', left: 'merchant', right: 'Expensify, Inc.', }, @@ -1037,6 +1037,50 @@ const tests = [ */ const keywordTests = [ + { + query: '-merchant*:Uber', + expected: { + type: 'expense', + sortBy: 'date', + sortOrder: 'desc', + view: 'table', + filters: { + operator: 'eq', + left: 'keyword', + right: ['-merchant*:Uber'], + }, + }, + }, + { + // The "contains" operator is only supported for the merchant field + query: 'category*:travel', + expected: { + type: 'expense', + sortBy: 'date', + sortOrder: 'desc', + view: 'table', + filters: { + operator: 'eq', + left: 'keyword', + right: ['category*:travel'], + }, + }, + }, + { + // The "contains" operator is not supported for default keys + query: 'type*:expense', + expected: { + type: 'expense', + sortBy: 'date', + sortOrder: 'desc', + view: 'table', + filters: { + operator: 'eq', + left: 'keyword', + right: ['type*:expense'], + }, + }, + }, { query: '" " " "', // Multiple whitespaces wrapped in quotes expected: { @@ -1329,6 +1373,20 @@ const keywordTests = [ }, { query: 'merchant:tax', + expected: { + type: 'expense', + sortBy: 'date', + sortOrder: 'desc', + view: 'table', + filters: { + operator: 'contains', + left: 'merchant', + right: 'tax', + }, + }, + }, + { + query: 'merchant=tax', expected: { type: 'expense', sortBy: 'date', @@ -1576,7 +1634,7 @@ const limitTests = [ view: 'table', limit: '50', filters: { - operator: 'eq', + operator: 'contains', left: 'merchant', right: 'Amazon', }, @@ -1653,7 +1711,7 @@ const footerSelectionTests = [ right: 'USD', }, right: { - operator: 'eq', + operator: 'contains', left: 'merchant', right: 'Amazon', }, @@ -1716,7 +1774,7 @@ const compareTests = [ groupBy: CONST.SEARCH.GROUP_BY.CATEGORY, compare: CONST.SEARCH.COMPARE.PREVIOUS_PERIOD, filters: { - operator: 'eq', + operator: 'contains', left: 'merchant', right: 'Amazon', }, diff --git a/tests/unit/hooks/useSearchBulkActionsTest.ts b/tests/unit/hooks/useSearchBulkActionsTest.ts index 369ca3dfc0d2..c3301fba1d8c 100644 --- a/tests/unit/hooks/useSearchBulkActionsTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsTest.ts @@ -6,7 +6,7 @@ import type {SearchQueryJSON, SelectedReports, SelectedTransactions} from '@comp import useSearchBulkActions from '@hooks/useSearchBulkActions'; import {unholdRequest} from '@libs/actions/IOU/Hold'; -import {getExportTemplates, queueExportSearchItemsToCSV, queueExportSearchWithTemplate} from '@libs/actions/Search'; +import {exportSearchItemsToCSV, getExportTemplates, queueExportSearchItemsToCSV, queueExportSearchWithTemplate} from '@libs/actions/Search'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -19,6 +19,7 @@ import createMock from '../../utils/createMock'; const mockQueueExportSearchItemsToCSV = jest.mocked(queueExportSearchItemsToCSV); const mockQueueExportSearchWithTemplate = jest.mocked(queueExportSearchWithTemplate); +const mockExportSearchItemsToCSV = jest.mocked(exportSearchItemsToCSV); const mockGetExportTemplates = jest.mocked(getExportTemplates); const mockUnholdRequest = jest.mocked(unholdRequest); @@ -373,6 +374,38 @@ describe('useSearchBulkActions - CSV export flow', () => { expect(exportPayload?.jsonQuery).not.toContain('group_123'); }); + it('exports a selected merchant group with an exact merchant match', async () => { + // Given a merchant-grouped search where the user ticked the "Amazon" group, so its loaded child is stamped with the group key + const merchantGroupKey = `${CONST.SEARCH.GROUP_PREFIX}123` as const; + mockSelectedTransactions = {tx1: makeSelectedTransaction({groupKey: merchantGroupKey, isSelectedViaGroup: true})}; + mockCurrentSearchResults = { + search: {type: CONST.SEARCH.DATA_TYPES.EXPENSE}, + data: { + [merchantGroupKey]: {merchant: 'Amazon', count: 3, total: 300, currency: 'USD'}, + }, + }; + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: groupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); + + await waitFor(() => { + expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0); + }); + + // When the user exports the current view of that group selection + const onSelected = getExportMenuItems(result.current.headerButtonsOptions).find((item) => item.text === 'export.currentView')?.onSelected; + + await act(async () => { + onSelected?.(); + }); + + // Then the backend receives the merchant filter as `eq`, because a `contains` match on "Amazon" would also + // export "Amazon Marketplace" expenses that are not in the selected group + const [exportPayload] = mockExportSearchItemsToCSV.mock.calls.at(-1) ?? []; + expect(exportPayload?.isGroupExport).toBe(true); + expect(exportPayload?.jsonQuery).toContain(`"operator":"${CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO}","left":"${CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT}","right":"Amazon"`); + expect(exportPayload?.jsonQuery).not.toContain(`"operator":"${CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS}"`); + }); + it('preserves excluded group negations when the query already filters the grouped field', async () => { const firstExcludedGroupKey = `${CONST.SEARCH.GROUP_PREFIX}123` as const; const secondExcludedGroupKey = `${CONST.SEARCH.GROUP_PREFIX}456` as const; diff --git a/tests/unit/hooks/useUpdateFilterQuery.test.ts b/tests/unit/hooks/useUpdateFilterQuery.test.ts index d4df7ec92cb0..193f216e9b2b 100644 --- a/tests/unit/hooks/useUpdateFilterQuery.test.ts +++ b/tests/unit/hooks/useUpdateFilterQuery.test.ts @@ -1,9 +1,10 @@ -import {renderHook} from '@testing-library/react-native'; +import {act, renderHook} from '@testing-library/react-native'; import useUpdateFilterQuery from '@components/Search/hooks/useUpdateFilterQuery'; import type {SearchQueryJSON} from '@components/Search/types'; import Navigation from '@libs/Navigation/Navigation'; +import {buildFilterFormValuesFromQuery, buildQueryStringWithResetFilters, buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -25,8 +26,9 @@ jest.mock('@components/Search/SearchContext', () => ({ })); jest.mock('@libs/Navigation/Navigation'); +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); -const queryJSON: SearchQueryJSON = { +const baseQueryJSON: SearchQueryJSON = { hash: 0, recentSearchHash: 0, similarSearchHash: 0, @@ -52,6 +54,7 @@ function getSetParamsCall() { describe('useUpdateFilterQuery', () => { beforeEach(() => { + jest.clearAllMocks(); onyxData[ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM] = {type: CONST.SEARCH.DATA_TYPES.EXPENSE} satisfies Partial; mockGetSearchKeyForQuery.mockReset(); mockGetSearchKeyForQuery.mockReturnValue(CONST.SEARCH.SEARCH_KEYS.REPORTS); @@ -60,7 +63,7 @@ describe('useUpdateFilterQuery', () => { describe('setFilterQueryParams', () => { it('writes the re-derived search key to the route when the type changes', () => { - const {result} = renderHook(() => useUpdateFilterQuery(queryJSON)); + const {result} = renderHook(() => useUpdateFilterQuery(baseQueryJSON)); result.current.setFilterQueryParams({type: CONST.SEARCH.DATA_TYPES.INVOICE}); @@ -70,7 +73,7 @@ describe('useUpdateFilterQuery', () => { it('clears the search key when the new query matches no search', () => { mockGetSearchKeyForQuery.mockReturnValue(undefined); - const {result} = renderHook(() => useUpdateFilterQuery(queryJSON)); + const {result} = renderHook(() => useUpdateFilterQuery(baseQueryJSON)); result.current.setFilterQueryParams({type: CONST.SEARCH.DATA_TYPES.INVOICE}); @@ -78,7 +81,7 @@ describe('useUpdateFilterQuery', () => { }); it('leaves the search key param alone when the type is set to the one the form already holds', () => { - const {result} = renderHook(() => useUpdateFilterQuery(queryJSON)); + const {result} = renderHook(() => useUpdateFilterQuery(baseQueryJSON)); result.current.setFilterQueryParams({type: CONST.SEARCH.DATA_TYPES.EXPENSE}); @@ -86,7 +89,7 @@ describe('useUpdateFilterQuery', () => { }); it('leaves the search key param alone when the type is unchanged', () => { - const {result} = renderHook(() => useUpdateFilterQuery(queryJSON)); + const {result} = renderHook(() => useUpdateFilterQuery(baseQueryJSON)); result.current.setFilterQueryParams({type: CONST.SEARCH.DATA_TYPES.EXPENSE, merchant: 'Amazon'}); @@ -94,11 +97,72 @@ describe('useUpdateFilterQuery', () => { }); it('leaves the search key param alone when no type is provided', () => { - const {result} = renderHook(() => useUpdateFilterQuery(queryJSON)); + const {result} = renderHook(() => useUpdateFilterQuery(baseQueryJSON)); result.current.setFilterQueryParams({merchant: 'Amazon'}); expect(getSetParamsCall()).not.toHaveProperty('searchKey'); }); }); + + function setup() { + const queryJSON = buildSearchQueryJSON('merchant=I merchant*:I'); + if (!queryJSON) { + throw new Error('Invalid test query'); + } + + const form = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}); + onyxData[ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM] = form; + + return {...renderHook(() => useUpdateFilterQuery(queryJSON)), form, queryJSON}; + } + + function getNavigatedQuery() { + const [params] = jest.mocked(Navigation.setParams).mock.calls.at(-1) ?? []; + if (!params || !('q' in params) || typeof params.q !== 'string') { + throw new Error('Expected navigation to a search query'); + } + + return buildSearchQueryJSON(params.q); + } + + test.each(['updateFilterQueryParams', 'setFilterQueryParams'] as const)('retains mixed Merchant predicates through %s', (method) => { + const {result, form, queryJSON} = setup(); + const updates: Partial = {currency: ['VND']}; + + act(() => { + result.current[method](method === 'setFilterQueryParams' ? {...form, ...updates} : updates); + }); + + expect(getNavigatedQuery()?.flatFilters.filter((filter) => filter.key === 'merchant')).toEqual(queryJSON.flatFilters); + expect(getNavigatedQuery()?.flatFilters).toContainEqual({key: 'currency', filters: [{operator: 'eq', value: 'VND'}]}); + }); + + test('removes all positive Merchant predicates when clearing the Merchant chip', () => { + const {result} = setup(); + + act(() => { + result.current.updateFilterQueryParams({merchant: undefined}); + }); + + expect(getNavigatedQuery()?.flatFilters).toEqual([]); + }); + + test('removes Merchant predicates when resetting without a default search', () => { + const {queryJSON} = setup(); + const resetQuery = buildSearchQueryJSON(buildQueryStringWithResetFilters(queryJSON, undefined)); + + expect(resetQuery?.flatFilters).toEqual([]); + }); + + test('restores default Merchant predicates when resetting filters', () => { + const {queryJSON} = setup(); + const defaultQuery = buildSearchQueryJSON('merchant*:Amazon,Uber'); + if (!defaultQuery) { + throw new Error('Invalid default query'); + } + const resetQuery = buildSearchQueryJSON(buildQueryStringWithResetFilters(queryJSON, defaultQuery)); + + expect(resetQuery?.flatFilters).toEqual(defaultQuery.flatFilters); + }); }); diff --git a/tests/unit/pages/Search/SearchSavePageTest.tsx b/tests/unit/pages/Search/SearchSavePageTest.tsx index 9ca581247845..258c06a1d59e 100644 --- a/tests/unit/pages/Search/SearchSavePageTest.tsx +++ b/tests/unit/pages/Search/SearchSavePageTest.tsx @@ -11,6 +11,7 @@ import * as SearchUIUtils from '@libs/SearchUIUtils'; import SearchSavePage from '@pages/Search/SearchSavePage'; import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; import type {Card, CardList} from '@src/types/onyx'; @@ -53,16 +54,24 @@ jest.mocked(useOnyx).mockImplementation((key) => { return [undefined, {status: 'loaded'}]; } }); +// Without a loaded locale, translate returns key paths and translated filter values never show the user's text. +beforeAll(() => IntlStore.load(CONST.LOCALES.EN)); beforeEach(() => jest.clearAllMocks()); it.each([[['12']], [['12', '23']], [['123']]])('renders canonical card selection %j exactly', (cardID) => { + // Given a saved search form with cards, feeds, tax rates and a Merchant filter that uses the default "contains" match form = {cardID, feed: ['feed-a', 'feed-b'], taxRate: ['tax-a', 'tax-b'], merchant: 'Coffee Shop'}; + + // When the save page renders the filter summary const output = JSON.stringify(render().toJSON()); + + // Then only the selected card titles, the feed and tax display values, and the localized Merchant label show const expectedDescriptions = cardID.at(0) === '123' ? [false, false, true] : [true, cardID.length === 2, false]; expect(['Selected Alpha', 'Selected Beta', 'Unselected Overlap'].map((text) => output.includes(text))).toEqual(expectedDescriptions); expect(['12', '23', '123'].map((rawID) => output.includes(rawID))).toEqual([false, false, false]); expect(jest.mocked(useFilterFeedValue)).toHaveBeenCalledWith(['feed-a', 'feed-b']); expect(jest.mocked(useFilterTaxRateValue)).toHaveBeenCalledWith(['tax-a', 'tax-b']); - expect(['feed:feed-a|feed-b', 'tax:tax-a|tax-b', 'Coffee Shop'].every((text) => output.includes(text))).toBe(true); + // The output is a JSON string, so the quotes in the Merchant label are escaped. + expect(['feed:feed-a|feed-b', 'tax:tax-a|tax-b', 'Contains \\"Coffee Shop\\"'].every((text) => output.includes(text))).toBe(true); }); it('renders scalar feed and tax values without calling array display hooks', () => { jest.spyOn(SearchUIUtils, 'mapFiltersFormToLabelValueList').mockReturnValueOnce([