Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ const defaultProps: Omit<TokenEditorProps, 'i18nStrings'> = {
disabled: false,
operators: [':', '!:'],
defaultOperator: ':',
getOperatorDescription: () => null,
},
filteringProperties: [nameProperty, dateProperty],
filteringOptions: [
Expand Down
135 changes: 135 additions & 0 deletions pages/property-filter/property-filter-freetext-operator.page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<I18nProvider locale="en" messages={[messages]}>
<div style={{ padding: 20 }}>
<h1>Property Filter — Free-text operator demo</h1>
<p>
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 <code>~</code> operator.
</p>

<PropertyFilter
{...propertyFilterProps}
filteringOptions={filteringOptions}
freeTextFiltering={freeTextFiltering}
i18nStrings={{
filteringAriaLabel: 'Filter instances',
dismissAriaLabel: 'Dismiss',
filteringPlaceholder: 'Filter instances',
groupValuesText: 'Values',
groupPropertiesText: 'Properties',
operatorsText: 'Operators',
operationAndText: 'and',
operationOrText: 'or',
operatorContainsText: 'Contains',
operatorDoesNotContainText: 'Does not contain',
operatorEqualsText: 'Equals',
operatorDoesNotEqualText: 'Does not equal',
operatorStartsWithText: 'Starts with',
operatorDoesNotStartWithText: 'Does not start with',
editTokenHeader: 'Edit filter',
propertyText: 'Property',
operatorText: 'Operator',
valueText: 'Value',
cancelActionText: 'Cancel',
applyActionText: 'Apply',
allPropertiesLabel: 'All properties',
clearFiltersText: 'Clear filters',
removeTokenButtonAriaLabel: token =>
`Remove filter, ${token.propertyLabel} ${token.operator} ${token.value}`,
enteredTextLabel: text => `Use: "${text}"`,
}}
/>

<h2>
Filtered items ({items.length} / {allItems.length})
</h2>
<h3>Current query</h3>
<pre>{JSON.stringify(propertyFilterProps.query, null, 2)}</pre>
<table style={{ borderCollapse: 'collapse', width: '100%', marginTop: 8 }}>
<thead>
<tr>
<th style={{ border: '1px solid #ccc', padding: 8, textAlign: 'left' }}>Name</th>
<th style={{ border: '1px solid #ccc', padding: 8, textAlign: 'left' }}>Environment</th>
<th style={{ border: '1px solid #ccc', padding: 8, textAlign: 'left' }}>Owner</th>
</tr>
</thead>
<tbody>
{items.map((item, i) => (
<tr key={i}>
<td style={{ border: '1px solid #ccc', padding: 8 }}>{item.name}</td>
<td style={{ border: '1px solid #ccc', padding: 8 }}>{item.env}</td>
<td style={{ border: '1px solid #ccc', padding: 8 }}>{item.owner}</td>
</tr>
))}
</tbody>
</table>
</div>
</I18nProvider>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ const defaultFreeTextFiltering: InternalFreeTextFiltering = {
disabled: false,
operators: [':', '!:'],
defaultOperator: ':',
getOperatorDescription: () => null,
};

const customGroupText: readonly GroupText[] = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
8 changes: 7 additions & 1 deletion src/property-filter/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/property-filter/internal-interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface InternalFreeTextFiltering {
disabled: boolean;
operators: readonly (PropertyFilterOperator | PropertyFilterTextOperatorExtended)[];
defaultOperator: PropertyFilterOperator;
getOperatorDescription: (operator: PropertyFilterOperator) => string | null;
}

export interface InternalToken<TokenValue = any> {
Expand Down
9 changes: 8 additions & 1 deletion src/property-filter/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
ExtendedOperator,
FilteringProperty,
PropertyFilterProps,
PropertyFilterTextOperatorExtended,
Ref,
Token,
TokenGroup,
Expand Down Expand Up @@ -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<PropertyFilterOperator, PropertyFilterTextOperatorExtended>()
);
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 };
Expand Down
6 changes: 6 additions & 0 deletions src/property-filter/token-editor-inputs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Expand Down
Loading