From e7b9bd9794cc0a6f49fdcbb5aba13572a82f7610 Mon Sep 17 00:00:00 2001 From: Ryan Evezard Date: Wed, 3 Jun 2026 07:25:38 -0400 Subject: [PATCH] feat: property filter custom operator support --- .../property-filter-custom-operator.page.tsx | 205 ++++++++++++++++++ ...operty-filter-editor-permutations.page.tsx | 4 + src/property-filter/__tests__/common.tsx | 39 +++- .../property-filter-controller.test.ts | 142 +++++++++++- src/property-filter/__tests__/utils.test.ts | 27 ++- src/property-filter/controller.ts | 15 +- src/property-filter/i18n-utils.ts | 28 ++- src/property-filter/interfaces.ts | 1 + src/property-filter/internal.tsx | 1 + src/property-filter/token-editor-inputs.tsx | 23 +- src/property-filter/token-editor.tsx | 1 + src/property-filter/utils.ts | 15 +- 12 files changed, 468 insertions(+), 33 deletions(-) create mode 100644 pages/property-filter/property-filter-custom-operator.page.tsx diff --git a/pages/property-filter/property-filter-custom-operator.page.tsx b/pages/property-filter/property-filter-custom-operator.page.tsx new file mode 100644 index 0000000000..b4ff99eb62 --- /dev/null +++ b/pages/property-filter/property-filter-custom-operator.page.tsx @@ -0,0 +1,205 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { useCollection } from '@cloudscape-design/collection-hooks'; + +import { I18nProvider } from '~components/i18n'; +import messages from '~components/i18n/messages/all.en'; +import PropertyFilter from '~components/property-filter'; +import { PropertyFilterProps } from '~components/property-filter/interfaces'; + +interface Item { + name: string; + env: string; + owner: string; + created: string; + priority: number; +} + +const allItems: Item[] = [ + { name: 'app-web-prod', env: 'production', owner: 'alice', created: '2025-01-15', priority: 1 }, + { name: 'app-api-prod', env: 'production', owner: 'bob', created: '2025-03-20', priority: 2 }, + { name: 'app-worker-staging', env: 'staging', owner: 'alice', created: '2025-06-01', priority: 3 }, + { name: 'app-web-dev', env: 'development', owner: 'charlie', created: '2025-08-10', priority: 1 }, + { name: 'svc-auth-prod', env: 'production', owner: 'bob', created: '2024-11-05', priority: 2 }, + { name: 'svc-data-staging', env: 'staging', owner: 'charlie', created: '2025-02-28', priority: 3 }, +]; + +const filteringProperties: PropertyFilterProps.FilteringProperty[] = [ + // Demo 1: Custom symbolic operators (~, !~) + { + key: 'name', + propertyLabel: 'Name', + groupValuesLabel: 'Name values', + operators: [ + '=', + '!=', + ':', + '!:', + { + operator: '~', + match: (itemValue: unknown, tokenValue: unknown) => { + try { + return new RegExp(String(tokenValue)).test(String(itemValue)); + } catch { + return String(itemValue).toLowerCase().includes(String(tokenValue).toLowerCase()); + } + }, + description: 'Matches regular expression', + }, + { + operator: '!~', + match: (itemValue: unknown, tokenValue: unknown) => { + try { + return !new RegExp(String(tokenValue)).test(String(itemValue)); + } catch { + return !String(itemValue).toLowerCase().includes(String(tokenValue).toLowerCase()); + } + }, + description: 'Does not match regular expression', + }, + ], + }, + // Demo 2: Description override on predefined operators (> = "After", < = "Before") + { + key: 'created', + propertyLabel: 'Created', + groupValuesLabel: 'Created values', + operators: [ + { operator: '=', description: 'On' }, + { operator: '>', description: 'After', match: 'date' as const }, + { operator: '<', description: 'Before', match: 'date' as const }, + { operator: '>=', description: 'On or after', match: 'date' as const }, + { operator: '<=', description: 'On or before', match: 'date' as const }, + ], + }, + // Demo 3: Text-based operators (in, NOT IN) + { + key: 'env', + propertyLabel: 'Environment', + groupValuesLabel: 'Environment values', + operators: [ + '=', + '!=', + { + operator: 'in', + match: (itemValue: unknown, tokenValue: unknown) => { + const values = String(tokenValue) + .split(',') + .map(v => v.trim().toLowerCase()); + return values.includes(String(itemValue).toLowerCase()); + }, + description: 'Is one of', + }, + { + operator: 'NOT IN', + match: (itemValue: unknown, tokenValue: unknown) => { + const values = String(tokenValue) + .split(',') + .map(v => v.trim().toLowerCase()); + return !values.includes(String(itemValue).toLowerCase()); + }, + description: 'Is not one of', + }, + ], + }, + { + key: 'owner', + propertyLabel: 'Owner', + groupValuesLabel: 'Owner values', + operators: ['=', '!='], + }, + { + key: 'priority', + propertyLabel: 'Priority', + groupValuesLabel: 'Priority values', + operators: ['=', '!=', '>', '<', '>=', '<='], + }, +]; + +const filteringOptions: PropertyFilterProps.FilteringOption[] = [...new Set(allItems.map(i => i.name))] + .map(v => ({ propertyKey: 'name', value: v })) + .concat([...new Set(allItems.map(i => i.env))].map(v => ({ propertyKey: 'env', value: v }))) + .concat([...new Set(allItems.map(i => i.owner))].map(v => ({ propertyKey: 'owner', value: v }))) + .concat([...new Set(allItems.map(i => String(i.priority)))].map(v => ({ propertyKey: 'priority', value: v }))); + +export default function CustomOperatorDemo() { + const { items, propertyFilterProps } = useCollection(allItems, { + propertyFiltering: { + filteringProperties, + }, + }); + + return ( + +
+

Property Filter — Custom Operator Demo

+ + + `Remove filter, ${token.propertyLabel} ${token.operator} ${token.value}`, + enteredTextLabel: text => `Use: "${text}"`, + }} + /> + +

+ Filtered items ({items.length} / {allItems.length}) +

+

Current query

+
{JSON.stringify(propertyFilterProps.query, null, 2)}
+ + + + + + + + + + + + {items.map((item, i) => ( + + + + + + + + ))} + +
NameEnvironmentOwnerCreatedPriority
{item.name}{item.env}{item.owner}{item.created}{item.priority}
+
+
+ ); +} diff --git a/pages/property-filter/property-filter-editor-permutations.page.tsx b/pages/property-filter/property-filter-editor-permutations.page.tsx index 8596f4ca37..9dbc82370d 100644 --- a/pages/property-filter/property-filter-editor-permutations.page.tsx +++ b/pages/property-filter/property-filter-editor-permutations.page.tsx @@ -35,6 +35,7 @@ const nameProperty: InternalFilteringProperty = { getTokenType: () => 'value', getValueFormatter: () => null, getValueFormRenderer: () => null, + getOperatorDescription: () => undefined, externalProperty, }; @@ -47,6 +48,7 @@ const stateProperty: InternalFilteringProperty = { getTokenType: () => 'enum', getValueFormatter: () => null, getValueFormRenderer: () => null, + getOperatorDescription: () => undefined, externalProperty, }; @@ -65,6 +67,7 @@ const dateProperty: InternalFilteringProperty = { ), + getOperatorDescription: () => undefined, externalProperty, }; @@ -88,6 +91,7 @@ const dateTimeProperty: InternalFilteringProperty = { ), + getOperatorDescription: () => undefined, externalProperty, }; diff --git a/src/property-filter/__tests__/common.tsx b/src/property-filter/__tests__/common.tsx index 9854dbfd23..7be6d59ca9 100644 --- a/src/property-filter/__tests__/common.tsx +++ b/src/property-filter/__tests__/common.tsx @@ -122,18 +122,33 @@ export const createDefaultProps = ( }); export function toInternalProperties(properties: FilteringProperty[]): InternalFilteringProperty[] { - return properties.map(property => ({ - propertyKey: property.key, - propertyLabel: property.propertyLabel, - groupValuesLabel: property.groupValuesLabel, - propertyGroup: property.group, - operators: (property.operators ?? []).map(op => (typeof op === 'string' ? op : op.operator)), - defaultOperator: property.defaultOperator ?? '=', - getTokenType: () => 'value', - getValueFormatter: () => null, - getValueFormRenderer: () => null, - externalProperty: property, - })); + return properties.map(property => { + const ops = property.operators ?? []; + const operatorStrings: string[] = []; + const extendedOperators = new Map(); + for (const op of ops) { + if (typeof op === 'object') { + operatorStrings.push(op.operator); + extendedOperators.set(op.operator, op); + } else { + operatorStrings.push(op); + } + } + return { + propertyKey: property.key, + propertyLabel: property.propertyLabel, + groupValuesLabel: property.groupValuesLabel, + propertyGroup: property.group, + operators: operatorStrings, + defaultOperator: property.defaultOperator ?? '=', + getTokenType: () => 'value', + getValueFormatter: () => null, + getValueFormRenderer: () => null, + getOperatorDescription: (operator?: string) => + operator ? extendedOperators.get(operator)?.description : undefined, + externalProperty: property, + }; + }); } export function StatefulPropertyFilter(props: PropertyFilterProps) { diff --git a/src/property-filter/__tests__/property-filter-controller.test.ts b/src/property-filter/__tests__/property-filter-controller.test.ts index 970b68a326..34e0c5b914 100644 --- a/src/property-filter/__tests__/property-filter-controller.test.ts +++ b/src/property-filter/__tests__/property-filter-controller.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { getAllowedOperators, getAutosuggestOptions, parseText } from '../controller'; +import { operatorToDescription } from '../i18n-utils'; import { ComparisonOperator, FilteringProperty, @@ -53,6 +54,20 @@ const filteringProperties = toInternalProperties([ defaultOperator: ':', groupValuesLabel: 'Custom column values', }, + // supports custom operator + { + key: 'custom-op', + propertyLabel: 'custom-op', + operators: ['=', { operator: '~', description: 'Matches regex' }], + groupValuesLabel: 'Custom op values', + }, + // supports text-based custom operator + { + key: 'text-op', + propertyLabel: 'status', + operators: ['=', '!=', 'in' as ComparisonOperator, 'NOT IN' as ComparisonOperator], + groupValuesLabel: 'Status values', + }, ]); const defaultFreeTextFiltering: InternalFreeTextFiltering = { @@ -105,8 +120,17 @@ describe('getAllowedOperators', () => { ['=', '!=', ':', '!:', '>=', '<=', '<', '>'], ], ['removes duplicates', getFilteringProperty(['=', { operator: '=' }]), ['=']], - ['removes unsupported', getFilteringProperty(['<>' as ComparisonOperator, '>', '=']), ['=', '>']], + [ + 'preserves custom operators after known ones', + getFilteringProperty(['<>' as ComparisonOperator, '>', '=']), + ['=', '>', '<>'], + ], ['adds default custom operator', getFilteringProperty(undefined, ':'), [':']], + [ + 'preserves custom operator with description', + getFilteringProperty([{ operator: '~', description: 'Matches regex' }]), + ['=', '~'], + ], ]; test.each(cases)('%s', (__description, input, expected) => { expect(getAllowedOperators(input)).toEqual(expected); @@ -241,6 +265,30 @@ describe('parseText', () => { defaultFreeTextFiltering, { step: 'operator', operatorPrefix: '', property: filteringProperties[0] }, ], + [ + 'custom operator', + 'custom-op~value', + defaultFreeTextFiltering, + { step: 'property', value: 'value', operator: '~', property: filteringProperties[6] }, + ], + [ + 'text-based operator with space', + 'status in active, pending', + defaultFreeTextFiltering, + { step: 'property', value: 'active, pending', operator: 'in', property: filteringProperties[7] }, + ], + [ + 'text-based operator case-insensitive', + 'status NOT IN active', + defaultFreeTextFiltering, + { step: 'property', value: 'active', operator: 'NOT IN', property: filteringProperties[7] }, + ], + [ + 'text-based operator not matched when part of value', + 'status =internal', + defaultFreeTextFiltering, + { step: 'property', value: 'internal', operator: '=', property: filteringProperties[7] }, + ], ]; test.each(cases)('%s', (__description, input, disableFreeTextFiltering, expected) => { expect(parseText(input, filteringProperties, disableFreeTextFiltering)).toEqual(expected); @@ -259,6 +307,8 @@ describe('getAutosuggestOptions', () => { { label: 'default', value: 'default', keepOpenOnSelect: true }, { label: 'string!=', value: 'string!=', keepOpenOnSelect: true }, { label: 'custom column', value: 'custom column', keepOpenOnSelect: true }, + { label: 'custom-op', value: 'custom-op', keepOpenOnSelect: true }, + { label: 'status', value: 'status', keepOpenOnSelect: true }, ], }, { options: [{ label: 'range', value: 'range', keepOpenOnSelect: true }], label: 'Group properties' }, @@ -419,4 +469,94 @@ describe('getAutosuggestOptions', () => { ); expect(actual).toEqual(expected); }); + test('returns custom operator with description in operator suggestions', () => { + const parsedText: ParsedText = { + step: 'operator', + operatorPrefix: '', + property: filteringProperties[6], + }; + const actual = getAutosuggestOptions( + parsedText, + filteringProperties, + filteringOptions, + customGroupText, + i18nStrings + ); + const operatorGroup = actual.options.find(group => group.label === 'Operators'); + expect(operatorGroup?.options).toEqual([ + { value: 'custom-op = ', label: 'custom-op =', description: 'Equals', keepOpenOnSelect: true }, + { value: 'custom-op ~ ', label: 'custom-op ~', description: 'Matches regex', keepOpenOnSelect: true }, + ]); + }); + test('custom description overrides built-in i18n for predefined operators', () => { + const overrideProperties = toInternalProperties([ + { + key: 'date', + propertyLabel: 'date', + operators: [{ operator: '>', description: 'After' }, { operator: '<', description: 'Before' }, '='], + groupValuesLabel: 'Date values', + }, + ]); + const parsedText: ParsedText = { + step: 'operator', + operatorPrefix: '', + property: overrideProperties[0], + }; + const actual = getAutosuggestOptions(parsedText, overrideProperties, [], customGroupText, i18nStrings); + const operatorGroup = actual.options.find(group => group.label === 'Operators'); + expect(operatorGroup?.options).toEqual([ + { value: 'date = ', label: 'date =', description: 'Equals', keepOpenOnSelect: true }, + { value: 'date < ', label: 'date <', description: 'Before', keepOpenOnSelect: true }, + { value: 'date > ', label: 'date >', description: 'After', keepOpenOnSelect: true }, + ]); + }); +}); + +describe('operatorToDescription', () => { + test('returns built-in i18n for known operators without custom description', () => { + const property = toInternalProperties([ + { key: 'k', propertyLabel: 'k', operators: ['=', '>'], groupValuesLabel: '' }, + ])[0]; + expect(operatorToDescription('=', i18nStrings, property)).toBe('Equals'); + expect(operatorToDescription('>', i18nStrings, property)).toBe('Greater than'); + }); + test('custom description overrides built-in i18n for known operators', () => { + const property = toInternalProperties([ + { + key: 'k', + propertyLabel: 'k', + operators: [ + { operator: '=', description: 'Exactly matches' }, + { operator: '>', description: 'After' }, + ], + groupValuesLabel: '', + }, + ])[0]; + expect(operatorToDescription('=', i18nStrings, property)).toBe('Exactly matches'); + expect(operatorToDescription('>', i18nStrings, property)).toBe('After'); + }); + test('returns custom description for unknown operators', () => { + const property = toInternalProperties([ + { + key: 'k', + propertyLabel: 'k', + operators: [{ operator: '~', description: 'Matches regex' }], + groupValuesLabel: '', + }, + ])[0]; + expect(operatorToDescription('~', i18nStrings, property)).toBe('Matches regex'); + }); + test('returns empty string for unknown operators without description', () => { + const property = toInternalProperties([ + { key: 'k', propertyLabel: 'k', operators: ['~' as ComparisonOperator], groupValuesLabel: '' }, + ])[0]; + expect(operatorToDescription('~', i18nStrings, property)).toBe(''); + }); + test('returns built-in i18n when no property is provided', () => { + expect(operatorToDescription('=', i18nStrings, undefined)).toBe('Equals'); + expect(operatorToDescription(':', i18nStrings, undefined)).toBe('Contains'); + }); + test('returns empty string for unknown operator when no property is provided', () => { + expect(operatorToDescription('~', i18nStrings, undefined)).toBe(''); + }); }); diff --git a/src/property-filter/__tests__/utils.test.ts b/src/property-filter/__tests__/utils.test.ts index 947b47fbe4..54e7cfca37 100644 --- a/src/property-filter/__tests__/utils.test.ts +++ b/src/property-filter/__tests__/utils.test.ts @@ -20,7 +20,8 @@ const filteringProperties = toInternalProperties([ }, ]); -const operators: ComparisonOperator[] = ['!:', ':', 'contains', 'does not contain'] as any; +const operators = ['!:', ':', 'contains', 'does not contain'] as ComparisonOperator[]; +const textOperators = ['=', '!=', 'in', 'NOT IN', ':'] as ComparisonOperator[]; describe('matchFilteringProperty', () => { test('should match property by label when filtering text equals to it', () => { @@ -82,6 +83,30 @@ describe('matchOperator', () => { const operator = matchOperator(operators, ' :'); expect(operator).toBe(null); }); + test('should not match text operator when followed by non-space character', () => { + const operator = matchOperator(textOperators, 'internal-server'); + expect(operator).toBe(null); + }); + test('should match text operator when followed by space', () => { + const operator = matchOperator(textOperators, 'in value1, value2'); + expect(operator).toBe('in'); + }); + test('should match text operator when at end of string', () => { + const operator = matchOperator(textOperators, 'in'); + expect(operator).toBe('in'); + }); + test('should match text operator case-insensitively', () => { + const operator = matchOperator(textOperators, 'not in value'); + expect(operator).toBe('NOT IN'); + }); + test('should prefer longer text operator match', () => { + const operator = matchOperator(textOperators, 'NOT IN value'); + expect(operator).toBe('NOT IN'); + }); + test('should still match symbolic operators without word boundary', () => { + const operator = matchOperator(textOperators, '!=value'); + expect(operator).toBe('!='); + }); }); describe('matchOperatorPrefix', () => { diff --git a/src/property-filter/controller.ts b/src/property-filter/controller.ts index 1db2118487..d62f26a593 100644 --- a/src/property-filter/controller.ts +++ b/src/property-filter/controller.ts @@ -94,17 +94,22 @@ export const getQueryActions = ({ return { addToken, updateToken, updateOperation, removeToken, removeAllTokens }; }; -const operatorOrder = ['=', '!=', ':', '!:', '^', '!^', '>=', '<=', '<', '>'] as const; +const knownOperatorOrder = ['=', '!=', ':', '!:', '^', '!^', '>=', '<=', '<', '>'] as const; export const getAllowedOperators = (property: InternalFilteringProperty): ComparisonOperator[] => { const { operators = [], defaultOperator } = property; const operatorSet = new Set([defaultOperator, ...operators]); - return operatorOrder.filter(op => operatorSet.has(op)); + const knownOperators = knownOperatorOrder.filter(op => operatorSet.has(op)); + const customOperators = operators.filter(op => !(knownOperatorOrder as readonly string[]).includes(op)); + return [...knownOperators, ...customOperators]; }; export const getAllowedFreeTextOperators = (freeText: InternalFreeTextFiltering): ComparisonOperator[] => { - const operatorSet = new Set(freeText.operators); - return operatorOrder.filter(op => operatorSet.has(op)); + const operators = freeText.operators.map(op => (typeof op === 'string' ? op : op.operator)); + const operatorSet = new Set(operators); + const knownOperators = knownOperatorOrder.filter(op => operatorSet.has(op)); + const customOperators = operators.filter(op => !(knownOperatorOrder as readonly string[]).includes(op)); + return [...knownOperators, ...customOperators]; }; /* @@ -291,7 +296,7 @@ export const getAutosuggestOptions = ( options: getAllowedOperators(parsedText.property).map(value => ({ value: parsedText.property.propertyLabel + ' ' + value + ' ', label: parsedText.property.propertyLabel + ' ' + value, - description: operatorToDescription(value, i18nStrings), + description: operatorToDescription(value, i18nStrings, parsedText.property), keepOpenOnSelect: true, })), label: i18nStrings.operatorsText, diff --git a/src/property-filter/i18n-utils.ts b/src/property-filter/i18n-utils.ts index aa5cd41692..20cc464911 100644 --- a/src/property-filter/i18n-utils.ts +++ b/src/property-filter/i18n-utils.ts @@ -2,7 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 import { useInternalI18n } from '../i18n/context'; -import { ComparisonOperator, FormattedToken, I18nStrings, InternalToken, InternalTokenGroup } from './interfaces'; +import { + ComparisonOperator, + FormattedToken, + I18nStrings, + InternalFilteringProperty, + InternalToken, + InternalTokenGroup, +} from './interfaces'; import { tokenGroupToTokens } from './utils'; export type I18nStringsOperators = Pick< @@ -112,7 +119,11 @@ export function usePropertyFilterI18n(def: I18nStrings = {}): I18nStringsInterna ), formatToken: token => { const formattedToken = toFormatted(token); - return { ...formattedToken, formattedText: formatToken(toFormatted(token)) }; + const customDescription = token.property?.getOperatorDescription(token.operator); + const formattedForAria = customDescription + ? { ...formattedToken, operator: customDescription.toLowerCase() } + : formattedToken; + return { ...formattedToken, formattedText: formatToken(formattedForAria) }; }, groupAriaLabel: group => { const tokens = tokenGroupToTokens(group.tokens).map(toFormatted); @@ -187,7 +198,16 @@ export function usePropertyFilterI18n(def: I18nStrings = {}): I18nStringsInterna }; } -export function operatorToDescription(operator: ComparisonOperator, i18nStrings: I18nStringsOperators) { +export function operatorToDescription( + operator: ComparisonOperator, + i18nStrings: I18nStringsOperators, + property?: InternalFilteringProperty +) { + const customDescription = property?.getOperatorDescription(operator); + if (customDescription !== undefined) { + return customDescription; + } + switch (operator) { case '<': return i18nStrings.operatorLessText; @@ -209,8 +229,6 @@ export function operatorToDescription(operator: ComparisonOperator, i18nStrings: return i18nStrings.operatorStartsWithText; case '!^': return i18nStrings.operatorDoesNotStartWithText; - // The line is ignored from coverage because it is not reachable. - // The purpose of it is to prevent TS errors if ComparisonOperator type gets extended. /* istanbul ignore next */ default: return ''; diff --git a/src/property-filter/interfaces.ts b/src/property-filter/interfaces.ts index 4ca9bb6083..01c0fa9c21 100644 --- a/src/property-filter/interfaces.ts +++ b/src/property-filter/interfaces.ts @@ -390,6 +390,7 @@ export interface InternalFilteringProperty { getTokenType: (operator?: PropertyFilterOperator) => PropertyFilterTokenType; getValueFormatter: (operator?: PropertyFilterOperator) => null | ((value: any) => string); getValueFormRenderer: (operator?: PropertyFilterOperator) => null | PropertyFilterOperatorForm; + getOperatorDescription: (operator?: PropertyFilterOperator) => string | undefined; // Original property used in callbacks. externalProperty: PropertyFilterProperty; } diff --git a/src/property-filter/internal.tsx b/src/property-filter/internal.tsx index 2e6ea1d496..e22f5bf873 100644 --- a/src/property-filter/internal.tsx +++ b/src/property-filter/internal.tsx @@ -142,6 +142,7 @@ const PropertyFilterInternal = React.forwardRef( getTokenType: operator => (operator ? (extendedOperators.get(operator)?.tokenType ?? 'value') : 'value'), getValueFormatter: operator => (operator ? (extendedOperators.get(operator)?.format ?? null) : null), getValueFormRenderer: operator => (operator ? (extendedOperators.get(operator)?.form ?? null) : null), + getOperatorDescription: operator => (operator ? extendedOperators.get(operator)?.description : undefined), externalProperty: property, }); return acc; diff --git a/src/property-filter/token-editor-inputs.tsx b/src/property-filter/token-editor-inputs.tsx index 8567501085..86e7c8e16f 100644 --- a/src/property-filter/token-editor-inputs.tsx +++ b/src/property-filter/token-editor-inputs.tsx @@ -87,6 +87,7 @@ interface OperatorInputProps { onChangeOperator: (operator: ComparisonOperator) => void; operator: undefined | ComparisonOperator; property: null | InternalFilteringProperty; + filteringProperties: readonly InternalFilteringProperty[]; freeTextFiltering: InternalFreeTextFiltering; triggerVariant: 'option' | 'label'; } @@ -96,14 +97,24 @@ export function OperatorInput({ operator, onChangeOperator, i18nStrings, + filteringProperties, freeTextFiltering, triggerVariant, }: OperatorInputProps) { - const operators = property ? getAllowedOperators(property) : getAllowedFreeTextOperators(freeTextFiltering); - const operatorOptions = operators.map(operator => ({ - value: operator, - label: operator, - description: operatorToDescription(operator, i18nStrings), + const getDescription = (op: ComparisonOperator) => { + if (property) { + return operatorToDescription(op, i18nStrings, property); + } + const freeTextProperty = filteringProperties.find(prop => prop.getOperatorDescription(op) !== undefined); + return operatorToDescription(op, i18nStrings, freeTextProperty); + }; + + const operatorOptions = ( + property ? getAllowedOperators(property) : getAllowedFreeTextOperators(freeTextFiltering) + ).map(op => ({ + value: op, + label: op, + description: getDescription(op), })); return ( diff --git a/src/property-filter/utils.ts b/src/property-filter/utils.ts index e018855c91..b4e0de79ee 100644 --- a/src/property-filter/utils.ts +++ b/src/property-filter/utils.ts @@ -49,14 +49,23 @@ export function matchOperator( allowedOperators: readonly ComparisonOperator[], filteringText: string ): null | ComparisonOperator { - filteringText = filteringText.toLowerCase(); + const lowerText = filteringText.toLowerCase(); let maxLength = 0; let matchedOperator: null | ComparisonOperator = null; for (const operator of allowedOperators) { - if (operator.length > maxLength && startsWith(filteringText, operator.toLowerCase())) { - maxLength = operator.length; + const lowerOp = operator.toLowerCase(); + if (lowerOp.length > maxLength && startsWith(lowerText, lowerOp)) { + // Text-based operators (containing letters) require a word boundary after the match + // to avoid matching the operator as a prefix of a value (e.g. "in" matching "internal"). + if (/[a-zA-Z]/.test(operator)) { + const charAfter = lowerText[lowerOp.length]; + if (charAfter !== undefined && charAfter !== ' ') { + continue; + } + } + maxLength = lowerOp.length; matchedOperator = operator; } }