From 4a895d32eb87c19b9e9d9fb3e2293589797f527c Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Thu, 23 Jul 2026 12:35:54 +0000 Subject: [PATCH] feat: allow property filter custom forms to submit tokens --- .../custom-form-submit.page.tsx | 107 +++++++++++++ .../__snapshots__/documenter.test.ts.snap | 2 +- ...roperty-filter-extended-operators.test.tsx | 140 +++++++++++++++++- src/property-filter/interfaces.ts | 46 +++++- src/property-filter/internal-interfaces.ts | 9 +- src/property-filter/internal.tsx | 24 ++- src/property-filter/property-editor.tsx | 4 +- src/property-filter/token-editor-inputs.tsx | 5 +- src/property-filter/token-editor.tsx | 1 + 9 files changed, 317 insertions(+), 21 deletions(-) create mode 100644 pages/property-filter/custom-form-submit.page.tsx diff --git a/pages/property-filter/custom-form-submit.page.tsx b/pages/property-filter/custom-form-submit.page.tsx new file mode 100644 index 0000000000..f8d7c40723 --- /dev/null +++ b/pages/property-filter/custom-form-submit.page.tsx @@ -0,0 +1,107 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import { useCollection } from '@cloudscape-design/collection-hooks'; + +import { I18nProvider } from '~components/i18n'; +import messages from '~components/i18n/messages/all.en'; +import Input from '~components/input'; +import PropertyFilter from '~components/property-filter'; +import { PropertyFilterProps } from '~components/property-filter/interfaces'; + +import { i18nStrings } from './common-props'; + +interface Item { + name: string; + score: number; +} + +const allItems: Item[] = [ + { name: 'alpha', score: 10 }, + { name: 'beta', score: 20 }, + { name: 'gamma', score: 30 }, + { name: 'delta', score: 40 }, +]; + +// Custom form using a Cloudscape Input. The standard Input does not submit the property filter form on +// "Enter" by itself, so we use the injected `submit` callback to apply the token on "Enter" — mirroring +// the built-in behaviour of components such as DatePicker. +function ScoreForm({ value, onChange, submit }: PropertyFilterProps.ExtendedOperatorFormProps) { + return ( + onChange(event.detail.value)} + onKeyDown={event => { + // keyCode 13 === Enter + if (event.detail.keyCode === 13) { + event.preventDefault(); + submit?.(); + } + }} + /> + ); +} + +const filteringProperties: PropertyFilterProps.FilteringProperty[] = [ + { + key: 'name', + propertyLabel: 'Name', + groupValuesLabel: 'Name values', + operators: ['=', '!=', ':', '!:'], + }, + { + key: 'score', + propertyLabel: 'Score', + groupValuesLabel: 'Score values', + operators: [ + { operator: '=', form: ScoreForm, match: (itemValue, tokenValue) => String(itemValue) === String(tokenValue) }, + { operator: '>', form: ScoreForm, match: (itemValue, tokenValue) => Number(itemValue) > Number(tokenValue) }, + { operator: '<', form: ScoreForm, match: (itemValue, tokenValue) => Number(itemValue) < Number(tokenValue) }, + ], + }, +]; + +export default function CustomFormSubmitDemo() { + const [query, setQuery] = useState({ tokens: [], operation: 'and' }); + const { items, propertyFilterProps } = useCollection(allItems, { + propertyFiltering: { filteringProperties }, + }); + + return ( + +
+

Property Filter — Custom Form Submit Demo

+

+ Type Score = (or Score > / Score <), enter a number in the custom + form, and press Enter to apply the token without clicking the Apply button. The same works when + editing an existing token. +

+ + { + propertyFilterProps.onChange(event); + setQuery(event.detail); + }} + i18nStrings={i18nStrings} + /> + +

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

+
{JSON.stringify(query, null, 2)}
+
    + {items.map((item, i) => ( +
  • + {item.name} — {item.score} +
  • + ))} +
+
+
+ ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index b54c1b56ab..4adcb05d91 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -23120,7 +23120,7 @@ exports[`Components definition for property-filter matches the snapshot: propert { "name": "operators", "optional": true, - "type": "ReadonlyArray>", + "type": "ReadonlyArray>", }, { "name": "propertyLabel", diff --git a/src/property-filter/__tests__/property-filter-extended-operators.test.tsx b/src/property-filter/__tests__/property-filter-extended-operators.test.tsx index cef6d41b93..5cd101ce9c 100644 --- a/src/property-filter/__tests__/property-filter-extended-operators.test.tsx +++ b/src/property-filter/__tests__/property-filter-extended-operators.test.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { act, render } from '@testing-library/react'; +import { act, fireEvent, render } from '@testing-library/react'; import { KeyCode } from '@cloudscape-design/test-utils-core/utils'; @@ -209,3 +209,141 @@ describe('extended operators', () => { expect(wrapper.findTokens()[1].getElement()).toHaveTextContent('index != NEQ2'); }); }); + +describe('extended operator form submit', () => { + // Custom form that lifts its value via onChange and applies it via the injected `submit` callback, + // either through a dedicated button or by pressing "Enter" inside the input. + const submittableProperty: FilteringProperty = { + key: 'index', + operators: [ + { + operator: '=', + form: ({ value, onChange, submit }) => ( +
+ onChange(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter' || event.keyCode === 13) { + submit?.(); + } + }} + /> + +
+ ), + }, + ], + propertyLabel: 'index', + groupValuesLabel: 'index value', + }; + const submittableProps = { filteringProperties: [submittableProperty] }; + + test('custom form receives a submit callback in the dropdown flow', () => { + const { propertyFilterWrapper: wrapper, pageWrapper } = renderComponent(submittableProps); + wrapper.setInputValue('index ='); + expect(pageWrapper.find('[data-testid="custom-submit"]')).not.toBe(null); + }); + + test('custom form submit applies the token in the dropdown flow (submit button)', () => { + const onChange = jest.fn(); + const { propertyFilterWrapper: wrapper, pageWrapper } = renderComponent({ ...submittableProps, onChange }); + + wrapper.setInputValue('index ='); + const input = pageWrapper.find('[data-testid="custom-input"]')!.getElement() as HTMLInputElement; + act(() => { + fireEvent.change(input, { target: { value: 'abc' } }); + }); + act(() => pageWrapper.find('[data-testid="custom-submit"]')!.click()); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { operation: 'and', tokens: [{ propertyKey: 'index', operator: '=', value: 'abc' }] }, + }) + ); + expect(wrapper.findDropdown()!.findOpenDropdown()).toBe(null); + expect(wrapper.findNativeInput().getElement()).toHaveFocus(); + }); + + test('custom form submit applies the token in the dropdown flow (Enter key)', () => { + const onChange = jest.fn(); + const { propertyFilterWrapper: wrapper, pageWrapper } = renderComponent({ ...submittableProps, onChange }); + + wrapper.setInputValue('index ='); + const input = pageWrapper.find('[data-testid="custom-input"]')!.getElement() as HTMLInputElement; + act(() => { + fireEvent.change(input, { target: { value: 'xyz' } }); + }); + act(() => { + fireEvent.keyDown(input, { key: 'Enter', keyCode: 13 }); + }); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { operation: 'and', tokens: [{ propertyKey: 'index', operator: '=', value: 'xyz' }] }, + }) + ); + expect(wrapper.findDropdown()!.findOpenDropdown()).toBe(null); + }); + + test('custom form submit applies changes from the token editor', () => { + const onChange = jest.fn(); + const { propertyFilterWrapper: wrapper, pageWrapper } = renderComponent({ + ...submittableProps, + onChange, + query: { tokens: [{ propertyKey: 'index', value: 'abc', operator: '=' }], operation: 'and' }, + }); + + const [contentWrapper] = openTokenEditor(wrapper, 0); + const input = contentWrapper.find('[data-testid="custom-input"]')!.getElement() as HTMLInputElement; + act(() => { + fireEvent.change(input, { target: { value: 'def' } }); + }); + act(() => pageWrapper.find('[data-testid="custom-submit"]')!.click()); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { operation: 'and', tokens: [{ propertyKey: 'index', operator: '=', value: 'def' }] }, + }) + ); + }); + + test('submit is optional: forms that ignore it keep working', () => { + // Regression guard for backward compatibility: a form that never calls submit still applies via the + // built-in submit button. + const onChange = jest.fn(); + const property: FilteringProperty = { + key: 'index', + operators: [ + { + operator: '=', + form: ({ value, onChange }) => ( + onChange(event.target.value)} /> + ), + }, + ], + propertyLabel: 'index', + groupValuesLabel: 'index value', + }; + const { propertyFilterWrapper: wrapper, pageWrapper } = renderComponent({ + filteringProperties: [property], + onChange, + }); + + wrapper.setInputValue('index ='); + const input = pageWrapper.find('[data-testid="legacy-input"]')!.getElement() as HTMLInputElement; + act(() => { + fireEvent.change(input, { target: { value: 'legacy' } }); + }); + act(() => wrapper.findPropertySubmitButton()!.click()); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { operation: 'and', tokens: [{ propertyKey: 'index', operator: '=', value: 'legacy' }] }, + }) + ); + }); +}); diff --git a/src/property-filter/interfaces.ts b/src/property-filter/interfaces.ts index 00cfc027b1..365f247520 100644 --- a/src/property-filter/interfaces.ts +++ b/src/property-filter/interfaces.ts @@ -7,7 +7,6 @@ import { PropertyFilterOperation, PropertyFilterOperator, PropertyFilterOperatorExtended, - PropertyFilterOperatorForm, PropertyFilterOperatorFormat, PropertyFilterOperatorFormProps, PropertyFilterOption, @@ -252,17 +251,50 @@ export interface PropertyFilterFreeTextFiltering { defaultOperator?: PropertyFilterOperator; } +// TODO: replace with PropertyFilterOperatorFormProps from collection-hooks once the `submit` property is released. +// Extends the collection-hooks operator form props with a `submit` callback that lets a custom form +// apply its value as a token programmatically (for example, on the "Enter" key). +export interface PropertyFilterOperatorFormPropsExtended + extends PropertyFilterOperatorFormProps { + /** + * Applies the current form value as a filtering token, equivalent to activating the form's built-in + * submit ("Apply") action. Use it to submit a custom form programmatically, for example from an + * `onKeyDown` handler that reacts to the "Enter" key inside an input. + * + * The value that gets applied is the last one propagated through `onChange`. Make sure to call + * `onChange` before `submit` so that the intended value is used. + */ + submit?: () => void; +} +// TODO: replace with PropertyFilterOperatorForm from collection-hooks once the `submit` property is released. +export type PropertyFilterOperatorFormExtended = React.FC< + PropertyFilterOperatorFormPropsExtended +>; + +// TODO: replace with PropertyFilterOperatorExtended from collection-hooks once the `submit` property is released. +// Mirrors the collection-hooks operator, but uses the augmented form renderer so that custom forms +// receive the `submit` callback. +export interface PropertyFilterOperatorExtendedAugmented + extends Omit, 'form'> { + form?: PropertyFilterOperatorFormExtended; +} +// TODO: replace with PropertyFilterProperty from collection-hooks once the `submit` property is released. +export interface PropertyFilterPropertyAugmented + extends Omit, 'operators'> { + operators?: readonly (PropertyFilterOperator | PropertyFilterOperatorExtendedAugmented)[]; +} + export namespace PropertyFilterProps { export type Token = PropertyFilterToken; export type TokenGroup = PropertyFilterTokenGroup; export type JoinOperation = PropertyFilterOperation; export type ComparisonOperator = PropertyFilterOperator; - export type ExtendedOperator = PropertyFilterOperatorExtended; - export type ExtendedOperatorFormProps = PropertyFilterOperatorFormProps; - export type ExtendedOperatorForm = PropertyFilterOperatorForm; + export type ExtendedOperator = PropertyFilterOperatorExtendedAugmented; + export type ExtendedOperatorFormProps = PropertyFilterOperatorFormPropsExtended; + export type ExtendedOperatorForm = PropertyFilterOperatorFormExtended; export type ExtendedOperatorFormat = PropertyFilterOperatorFormat; export type FilteringOption = PropertyFilterOption; - export type FilteringProperty = PropertyFilterProperty; + export type FilteringProperty = PropertyFilterPropertyAugmented; export type FreeTextFiltering = PropertyFilterFreeTextFiltering; export type Query = PropertyFilterQuery; export type StatusType = DropdownStatusProps.StatusType; @@ -372,8 +404,8 @@ export type Token = PropertyFilterProps.Token; export type TokenGroup = PropertyFilterProps.TokenGroup; export type JoinOperation = PropertyFilterProps.JoinOperation; export type ComparisonOperator = PropertyFilterProps.ComparisonOperator; -export type ExtendedOperator = PropertyFilterOperatorExtended; -export type ExtendedOperatorForm = PropertyFilterOperatorForm; +export type ExtendedOperator = PropertyFilterOperatorExtendedAugmented; +export type ExtendedOperatorForm = PropertyFilterOperatorFormExtended; export type FilteringOption = PropertyFilterProps.FilteringOption; export type FilteringProperty = PropertyFilterProps.FilteringProperty; export type Query = PropertyFilterProps.Query; diff --git a/src/property-filter/internal-interfaces.ts b/src/property-filter/internal-interfaces.ts index 6a7d36d107..b5e713409a 100644 --- a/src/property-filter/internal-interfaces.ts +++ b/src/property-filter/internal-interfaces.ts @@ -4,12 +4,15 @@ import { PropertyFilterOperation, PropertyFilterOperator, - PropertyFilterOperatorForm, PropertyFilterProperty, PropertyFilterTokenType, } from '@cloudscape-design/collection-hooks'; -import { ComparisonOperator, PropertyFilterTextOperatorExtended } from './interfaces'; +import { + ComparisonOperator, + PropertyFilterOperatorFormExtended, + PropertyFilterTextOperatorExtended, +} from './interfaces'; // Utility types @@ -22,7 +25,7 @@ export interface InternalFilteringProperty { defaultOperator: PropertyFilterOperator; getTokenType: (operator?: PropertyFilterOperator) => PropertyFilterTokenType; getValueFormatter: (operator?: PropertyFilterOperator) => null | ((value: any) => string); - getValueFormRenderer: (operator?: PropertyFilterOperator) => null | PropertyFilterOperatorForm; + getValueFormRenderer: (operator?: PropertyFilterOperator) => null | PropertyFilterOperatorFormExtended; getOperatorDescription: (operator?: PropertyFilterOperator) => string | null; // Original property used in callbacks. externalProperty: PropertyFilterProperty; diff --git a/src/property-filter/internal.tsx b/src/property-filter/internal.tsx index 3dc81456fd..431fa55203 100644 --- a/src/property-filter/internal.tsx +++ b/src/property-filter/internal.tsx @@ -367,6 +367,16 @@ const PropertyFilterInternal = React.forwardRef( const operatorForm = propertyStep && propertyStep.property.getValueFormRenderer(propertyStep.operator); const isEnumValue = propertyStep?.property.getTokenType(propertyStep.operator) === 'enum'; + // Applies a custom-form token and resets the input. Shared by the dropdown footer button and the + // `submit` callback exposed to custom operator forms, so that programmatic submission (e.g. on the + // "Enter" key) behaves identically to clicking the "Apply" button. + const submitCustomFormToken = (token: InternalToken) => { + addToken(token); + setFilteringText(''); + inputRef.current?.focus({ preventDropdown: true }); + inputRef.current?.close(); + }; + const searchResultsId = useUniqueId('property-filter-search-results'); const constraintTextId = useUniqueId('property-filter-constraint'); const textboxAriaDescribedBy = filteringConstraintText @@ -409,6 +419,13 @@ const PropertyFilterInternal = React.forwardRef( operatorForm={operatorForm} value={customFormValue} onChange={setCustomFormValue} + submit={() => + submitCustomFormToken({ + property: propertyStep.property, + operator: propertyStep.operator, + value: customFormValue, + }) + } /> ) : ( { - addToken(token); - setFilteringText(''); - inputRef.current?.focus({ preventDropdown: true }); - inputRef.current?.close(); - }} + onSubmit={submitCustomFormToken} /> ), } diff --git a/src/property-filter/property-editor.tsx b/src/property-filter/property-editor.tsx index 9facae1a6f..de274b14ae 100644 --- a/src/property-filter/property-editor.tsx +++ b/src/property-filter/property-editor.tsx @@ -27,6 +27,7 @@ export function PropertyEditorContentCustom({ filter, value, onChange, + submit, operatorForm, }: { property: InternalFilteringProperty; @@ -34,6 +35,7 @@ export function PropertyEditorContentCustom({ filter: string; value: null | TokenValue; onChange: (value: null | TokenValue) => void; + submit: () => void; operatorForm: ExtendedOperatorForm; }) { const labelId = useUniqueId(); @@ -45,7 +47,7 @@ export function PropertyEditorContentCustom({
- {operatorForm({ value, onChange, operator, filter })} + {operatorForm({ value, onChange, operator, filter, submit })}
diff --git a/src/property-filter/token-editor-inputs.tsx b/src/property-filter/token-editor-inputs.tsx index 2b7325b71d..c89fc140a6 100644 --- a/src/property-filter/token-editor-inputs.tsx +++ b/src/property-filter/token-editor-inputs.tsx @@ -138,17 +138,18 @@ interface ValueInputProps { i18nStrings: I18nStringsInternal; onChangeValue: (value: unknown) => void; onLoadItems?: NonCancelableEventHandler; + onSubmit?: () => void; operator: undefined | ComparisonOperator; property: null | InternalFilteringProperty; value: unknown; } export function ValueInput(props: ValueInputProps) { - const { property, operator, value, onChangeValue } = props; + const { property, operator, value, onChangeValue, onSubmit } = props; const OperatorForm = property?.propertyKey && operator && property?.getValueFormRenderer(operator); if (OperatorForm) { - return ; + return ; } if (property && operator && property.getTokenType(operator) === 'enum') { return ; diff --git a/src/property-filter/token-editor.tsx b/src/property-filter/token-editor.tsx index 9bea05a128..63793efe94 100644 --- a/src/property-filter/token-editor.tsx +++ b/src/property-filter/token-editor.tsx @@ -174,6 +174,7 @@ export function TokenEditor({ operator={groups[index].operator} value={groups[index].value} onChangeValue={groups[index].onChangeValue} + onSubmit={onSubmit} asyncProps={asyncProps} filteringOptions={filteringOptions} onLoadItems={onLoadItems}