From 027bb6b5ac101130321e6be5e2ec299462cefe1e Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Thu, 23 Jul 2026 13:45:36 +0000 Subject: [PATCH 1/3] feat: Add description support for PropertyFilter free text operators --- ...property-filter-freetext-operator.page.tsx | 135 ++++++++++++++++++ .../property-filter-controller.test.ts | 1 + .../property-filter-token-editor.test.tsx | 42 ++++++ src/property-filter/interfaces.ts | 8 +- src/property-filter/internal-interfaces.ts | 1 + src/property-filter/internal.tsx | 9 +- src/property-filter/token-editor-inputs.tsx | 6 + 7 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 pages/property-filter/property-filter-freetext-operator.page.tsx diff --git a/pages/property-filter/property-filter-freetext-operator.page.tsx b/pages/property-filter/property-filter-freetext-operator.page.tsx new file mode 100644 index 0000000000..d239257725 --- /dev/null +++ b/pages/property-filter/property-filter-freetext-operator.page.tsx @@ -0,0 +1,135 @@ +// 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; +} + +const allItems: Item[] = [ + { name: 'app-web-prod', env: 'production', owner: 'alice' }, + { name: 'app-api-prod', env: 'production', owner: 'bob' }, + { name: 'app-worker-staging', env: 'staging', owner: 'alice' }, + { name: 'app-web-dev', env: 'development', owner: 'charlie' }, + { name: 'svc-auth-prod', env: 'production', owner: 'bob' }, + { name: 'svc-data-staging', env: 'staging', owner: 'charlie' }, +]; + +const filteringProperties: PropertyFilterProps.FilteringProperty[] = [ + { key: 'name', propertyLabel: 'Name', groupValuesLabel: 'Name values', operators: ['=', '!=', ':', '!:'] }, + { key: 'env', propertyLabel: 'Environment', groupValuesLabel: 'Environment values', operators: ['=', '!='] }, + { key: 'owner', propertyLabel: 'Owner', groupValuesLabel: 'Owner 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 }))); + +// Free-text filtering that lets consumers control the operator used for free text: +// - `defaultOperator: '='` makes plain typed text an exact-match token instead of the default "contains". +// - each operator carries a custom `description` shown in the operator dropdown. +// - `~` is a custom free-text operator string with its own `match` function and `description`. +const freeTextFiltering: PropertyFilterProps.FreeTextFiltering = { + defaultOperator: '=', + operators: [ + { operator: '=', description: 'Exact match' }, + { operator: ':', description: 'Contains' }, + { operator: '^', description: 'Starts with' }, + { + operator: '~', + description: 'Matches regular expression', + match: (itemValue: unknown, tokenValue: unknown) => { + try { + return new RegExp(String(tokenValue)).test(String(itemValue)); + } catch { + return String(itemValue).toLowerCase().includes(String(tokenValue).toLowerCase()); + } + }, + }, + ], +}; + +export default function FreeTextOperatorDemo() { + const { items, propertyFilterProps } = useCollection(allItems, { + propertyFiltering: { filteringProperties }, + }); + + return ( + +
+

Property Filter — Free-text operator demo

+

+ Open the operator dropdown of a free-text token (or create one by typing) to see consumer-controlled operators + with custom descriptions, including the custom ~ operator. +

+ + + `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) => ( + + + + + + ))} + +
NameEnvironmentOwner
{item.name}{item.env}{item.owner}
+
+
+ ); +} diff --git a/src/property-filter/__tests__/property-filter-controller.test.ts b/src/property-filter/__tests__/property-filter-controller.test.ts index db33566295..46a7d96647 100644 --- a/src/property-filter/__tests__/property-filter-controller.test.ts +++ b/src/property-filter/__tests__/property-filter-controller.test.ts @@ -68,6 +68,7 @@ const defaultFreeTextFiltering: InternalFreeTextFiltering = { disabled: false, operators: [':', '!:'], defaultOperator: ':', + getOperatorDescription: () => null, }; const customGroupText: readonly GroupText[] = [ diff --git a/src/property-filter/__tests__/property-filter-token-editor.test.tsx b/src/property-filter/__tests__/property-filter-token-editor.test.tsx index 354f54751e..a8835c817e 100644 --- a/src/property-filter/__tests__/property-filter-token-editor.test.tsx +++ b/src/property-filter/__tests__/property-filter-token-editor.test.tsx @@ -272,6 +272,48 @@ describe.each([false, true])('token editor, expandToViewport=%s', expandToViewpo ).toEqual(['=Equals', '!=Does not equal', ':Contains', '!:Does not contain']); }); + test('uses custom descriptions provided for free text operators', () => { + renderComponent({ + freeTextFiltering: { + operators: [ + { operator: '=', description: 'Exact match' }, + { operator: ':', description: 'Includes' }, + // custom operator string with a description and no built-in i18n fallback + { operator: '~', description: 'Matches regex' }, + ], + }, + query: { tokens: [{ value: 'first', operator: '=' }], operation: 'or' }, + }); + const editor = openEditor(0, { expandToViewport }); + // The token trigger shows the operator symbol together with its custom description. + expect(editor.operatorField().getElement()).toHaveTextContent('=Exact match'); + act(() => editor.operatorSelect().openDropdown()); + expect( + editor + .operatorSelect() + .findDropdown() + .findOptions()! + .map(optionWrapper => optionWrapper.getElement().textContent) + ).toEqual(['=Exact match', ':Includes', '~Matches regex']); + }); + + test('falls back to the built-in description when no custom free text description is provided', () => { + renderComponent({ + // Mixed: `=` carries a custom description, `:` relies on the built-in i18n description. + freeTextFiltering: { operators: [{ operator: '=', description: 'Exact match' }, ':'] }, + query: { tokens: [{ value: 'first', operator: ':' }], operation: 'or' }, + }); + const editor = openEditor(0, { expandToViewport }); + act(() => editor.operatorSelect().openDropdown()); + expect( + editor + .operatorSelect() + .findDropdown() + .findOptions()! + .map(optionWrapper => optionWrapper.getElement().textContent) + ).toEqual(['=Exact match', ':Contains']); + }); + test('enables client-side filtering on property options', () => { renderComponent({ query: { tokens: [{ propertyKey: 'string', value: 'first', operator: ':' }], operation: 'or' }, diff --git a/src/property-filter/interfaces.ts b/src/property-filter/interfaces.ts index 00cfc027b1..38ee6e7c65 100644 --- a/src/property-filter/interfaces.ts +++ b/src/property-filter/interfaces.ts @@ -144,7 +144,7 @@ export interface PropertyFilterProps extends BaseComponentProps, ExpandToViewpor /** * An object configuring the operators for free text filtering, which has the following properties: * - * * operators [Array]: A list of all operators supported for free text filtering. Each entry is either an operator string, or an object with an `operator` string and an optional `match` function `(item, text) => boolean` for custom matching. Custom operator strings are supported and are appended after the built-in operators. If you omit the contains operator because your API does not support it, make sure to set `defaultOperator` to a supported operator from this list. + * * operators [Array]: A list of all operators supported for free text filtering. Each entry is either an operator string, or an object with an `operator` string and the following optional properties: a `match` function `(item, text) => boolean` for custom matching, and a `description` string shown next to the operator in the operator dropdown. Custom operator strings are supported and are appended after the built-in operators. If you omit the contains operator because your API does not support it, make sure to set `defaultOperator` to a supported operator from this list. * * defaultOperator [ComparisonOperator]: An optional parameter that changes the default operator used for free text filtering. Use this parameter only if your API does not support "contains" free test filtering terms. */ freeTextFiltering?: PropertyFilterProps.FreeTextFiltering; @@ -245,6 +245,12 @@ export interface PropertyFilterProps extends BaseComponentProps, ExpandToViewpor export interface PropertyFilterTextOperatorExtended { operator: PropertyFilterOperator; match?: (item: unknown, text: string) => boolean; + /** + * A human-readable description shown next to the operator in the operator dropdown. Use it to describe a + * custom free-text operator (for example `~` as "Matches regex"), or to override the built-in description + * of a predefined operator (for example, describing `=` as "Exact match" for free text filtering). + */ + description?: string; } // TODO: replace with PropertyFilterFreeTextFiltering from collection-hooks once it is released export interface PropertyFilterFreeTextFiltering { diff --git a/src/property-filter/internal-interfaces.ts b/src/property-filter/internal-interfaces.ts index 6a7d36d107..670c2c66c4 100644 --- a/src/property-filter/internal-interfaces.ts +++ b/src/property-filter/internal-interfaces.ts @@ -40,6 +40,7 @@ export interface InternalFreeTextFiltering { disabled: boolean; operators: readonly (PropertyFilterOperator | PropertyFilterTextOperatorExtended)[]; defaultOperator: PropertyFilterOperator; + getOperatorDescription: (operator: PropertyFilterOperator) => string | null; } export interface InternalToken { diff --git a/src/property-filter/internal.tsx b/src/property-filter/internal.tsx index 3dc81456fd..0ac11258cd 100644 --- a/src/property-filter/internal.tsx +++ b/src/property-filter/internal.tsx @@ -29,6 +29,7 @@ import { ExtendedOperator, FilteringProperty, PropertyFilterProps, + PropertyFilterTextOperatorExtended, Ref, Token, TokenGroup, @@ -187,10 +188,16 @@ const PropertyFilterInternal = React.forwardRef( tokens: (enableTokenGroups && query.tokenGroups ? query.tokenGroups : query.tokens).map(transformToken), }; + const freeTextOperators = freeTextFiltering?.operators ?? [':', '!:']; + const extendedFreeTextOperators = freeTextOperators.reduce( + (acc, operator) => (typeof operator === 'object' ? acc.set(operator.operator, operator) : acc), + new Map() + ); const internalFreeText: InternalFreeTextFiltering = { disabled: disableFreeTextFiltering, - operators: freeTextFiltering?.operators ?? [':', '!:'], + operators: freeTextOperators, defaultOperator: freeTextFiltering?.defaultOperator ?? ':', + getOperatorDescription: operator => extendedFreeTextOperators.get(operator)?.description ?? null, }; return { internalProperties: [...propertyByKey.values()], internalOptions, internalQuery, internalFreeText }; diff --git a/src/property-filter/token-editor-inputs.tsx b/src/property-filter/token-editor-inputs.tsx index 2b7325b71d..a3c838c6e8 100644 --- a/src/property-filter/token-editor-inputs.tsx +++ b/src/property-filter/token-editor-inputs.tsx @@ -103,6 +103,12 @@ export function OperatorInput({ if (property) { return operatorToDescription(op, i18nStrings, property); } + // Prefer an explicit free-text operator description when the consumer provides one. + const freeTextDescription = freeTextFiltering.getOperatorDescription(op); + if (freeTextDescription) { + return freeTextDescription; + } + // Fall back to reusing a matching property operator description, then to the built-in i18n string. const freeTextProperty = filteringProperties.find(prop => prop.getOperatorDescription(op) !== null); return operatorToDescription(op, i18nStrings, freeTextProperty); }; From 9148b5a4d6e5b535ebe6dfa95c5952917fa7123a Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Thu, 23 Jul 2026 16:54:06 +0000 Subject: [PATCH 2/3] fix: add getOperatorDescription to permutations page freeTextFiltering InternalFreeTextFiltering now requires getOperatorDescription, so the freeTextFiltering literal in the editor-permutations page defaultProps failed buildPagesStatic with TS2741. Add getOperatorDescription: () => null to match the other filtering objects on this page. --- .../property-filter/property-filter-editor-permutations.page.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/pages/property-filter/property-filter-editor-permutations.page.tsx b/pages/property-filter/property-filter-editor-permutations.page.tsx index 6577b47ca7..14c92f40ca 100644 --- a/pages/property-filter/property-filter-editor-permutations.page.tsx +++ b/pages/property-filter/property-filter-editor-permutations.page.tsx @@ -103,6 +103,7 @@ const defaultProps: Omit = { disabled: false, operators: [':', '!:'], defaultOperator: ':', + getOperatorDescription: () => null, }, filteringProperties: [nameProperty, dateProperty], filteringOptions: [ From 1a1aabf420f1fde69658323d2ee14e8843884b89 Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Thu, 23 Jul 2026 17:09:46 +0000 Subject: [PATCH 3/3] test: regenerate property-filter documenter snapshot The free-text operator description feature updated the public operators doc string and added PropertyFilterTextOperatorExtended.description; refresh the documenter snapshot to match. Previously masked by the editor-permutations dev-page compile fast-fail. --- .../snapshot-tests/__snapshots__/documenter.test.ts.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index b54c1b56ab..a03dc318db 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -23382,7 +23382,7 @@ Use the \`onLoadItems\` event to perform a recovery action (for example, retryin { "description": "An object configuring the operators for free text filtering, which has the following properties: -* operators [Array]: A list of all operators supported for free text filtering. Each entry is either an operator string, or an object with an \`operator\` string and an optional \`match\` function \`(item, text) => boolean\` for custom matching. Custom operator strings are supported and are appended after the built-in operators. If you omit the contains operator because your API does not support it, make sure to set \`defaultOperator\` to a supported operator from this list. +* operators [Array]: A list of all operators supported for free text filtering. Each entry is either an operator string, or an object with an \`operator\` string and the following optional properties: a \`match\` function \`(item, text) => boolean\` for custom matching, and a \`description\` string shown next to the operator in the operator dropdown. Custom operator strings are supported and are appended after the built-in operators. If you omit the contains operator because your API does not support it, make sure to set \`defaultOperator\` to a supported operator from this list. * defaultOperator [ComparisonOperator]: An optional parameter that changes the default operator used for free text filtering. Use this parameter only if your API does not support "contains" free test filtering terms.", "inlineType": { "name": "PropertyFilterFreeTextFiltering",