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
107 changes: 107 additions & 0 deletions pages/property-filter/custom-form-submit.page.tsx
Original file line number Diff line number Diff line change
@@ -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<string>) {
return (
<Input
type="number"
value={value ?? ''}
placeholder="Enter a score, then press Enter"
onChange={event => 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<PropertyFilterProps.Query>({ tokens: [], operation: 'and' });
const { items, propertyFilterProps } = useCollection(allItems, {
propertyFiltering: { filteringProperties },
});

return (
<I18nProvider locale="en" messages={[messages]}>
<div style={{ padding: 20 }}>
<h1>Property Filter — Custom Form Submit Demo</h1>
<p>
Type <code>Score =</code> (or <code>Score &gt;</code> / <code>Score &lt;</code>), enter a number in the custom
form, and press <kbd>Enter</kbd> to apply the token without clicking the Apply button. The same works when
editing an existing token.
</p>

<PropertyFilter
{...propertyFilterProps}
query={query}
onChange={event => {
propertyFilterProps.onChange(event);
setQuery(event.detail);
}}
i18nStrings={i18nStrings}
/>

<h2>
Filtered items ({items.length} / {allItems.length})
</h2>
<pre>{JSON.stringify(query, null, 2)}</pre>
<ul>
{items.map((item, i) => (
<li key={i}>
{item.name} — {item.score}
</li>
))}
</ul>
</div>
</I18nProvider>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -23120,7 +23120,7 @@ exports[`Components definition for property-filter matches the snapshot: propert
{
"name": "operators",
"optional": true,
"type": "ReadonlyArray<string | PropertyFilterOperatorExtended<TokenValue>>",
"type": "ReadonlyArray<string | PropertyFilterOperatorExtendedAugmented<TokenValue>>",
},
{
"name": "propertyLabel",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 }) => (
<div>
<input
data-testid="custom-input"
value={value ?? ''}
onChange={event => onChange(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter' || event.keyCode === 13) {
submit?.();
}
}}
/>
<button data-testid="custom-submit" onClick={() => submit?.()}>
apply
</button>
</div>
),
},
],
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 }) => (
<input data-testid="legacy-input" value={value ?? ''} onChange={event => 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' }] },
})
);
});
});
46 changes: 39 additions & 7 deletions src/property-filter/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
PropertyFilterOperation,
PropertyFilterOperator,
PropertyFilterOperatorExtended,
PropertyFilterOperatorForm,
PropertyFilterOperatorFormat,
PropertyFilterOperatorFormProps,
PropertyFilterOption,
Expand Down Expand Up @@ -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<TokenValue>
extends PropertyFilterOperatorFormProps<TokenValue> {
/**
* 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<TokenValue> = React.FC<
PropertyFilterOperatorFormPropsExtended<TokenValue>
>;

// 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<TokenValue>
extends Omit<PropertyFilterOperatorExtended<TokenValue>, 'form'> {
form?: PropertyFilterOperatorFormExtended<TokenValue>;
}
// TODO: replace with PropertyFilterProperty from collection-hooks once the `submit` property is released.
export interface PropertyFilterPropertyAugmented<TokenValue = any>
extends Omit<PropertyFilterProperty<TokenValue>, 'operators'> {
operators?: readonly (PropertyFilterOperator | PropertyFilterOperatorExtendedAugmented<TokenValue>)[];
}

export namespace PropertyFilterProps {
export type Token = PropertyFilterToken;
export type TokenGroup = PropertyFilterTokenGroup;
export type JoinOperation = PropertyFilterOperation;
export type ComparisonOperator = PropertyFilterOperator;
export type ExtendedOperator<TokenValue> = PropertyFilterOperatorExtended<TokenValue>;
export type ExtendedOperatorFormProps<TokenValue> = PropertyFilterOperatorFormProps<TokenValue>;
export type ExtendedOperatorForm<TokenValue> = PropertyFilterOperatorForm<TokenValue>;
export type ExtendedOperator<TokenValue> = PropertyFilterOperatorExtendedAugmented<TokenValue>;
export type ExtendedOperatorFormProps<TokenValue> = PropertyFilterOperatorFormPropsExtended<TokenValue>;
export type ExtendedOperatorForm<TokenValue> = PropertyFilterOperatorFormExtended<TokenValue>;
export type ExtendedOperatorFormat<TokenValue> = PropertyFilterOperatorFormat<TokenValue>;
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;
Expand Down Expand Up @@ -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<TokenValue> = PropertyFilterOperatorExtended<TokenValue>;
export type ExtendedOperatorForm<TokenValue> = PropertyFilterOperatorForm<TokenValue>;
export type ExtendedOperator<TokenValue> = PropertyFilterOperatorExtendedAugmented<TokenValue>;
export type ExtendedOperatorForm<TokenValue> = PropertyFilterOperatorFormExtended<TokenValue>;
export type FilteringOption = PropertyFilterProps.FilteringOption;
export type FilteringProperty = PropertyFilterProps.FilteringProperty;
export type Query = PropertyFilterProps.Query;
Expand Down
9 changes: 6 additions & 3 deletions src/property-filter/internal-interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -22,7 +25,7 @@ export interface InternalFilteringProperty<TokenValue = any> {
defaultOperator: PropertyFilterOperator;
getTokenType: (operator?: PropertyFilterOperator) => PropertyFilterTokenType;
getValueFormatter: (operator?: PropertyFilterOperator) => null | ((value: any) => string);
getValueFormRenderer: (operator?: PropertyFilterOperator) => null | PropertyFilterOperatorForm<TokenValue>;
getValueFormRenderer: (operator?: PropertyFilterOperator) => null | PropertyFilterOperatorFormExtended<TokenValue>;
getOperatorDescription: (operator?: PropertyFilterOperator) => string | null;
// Original property used in callbacks.
externalProperty: PropertyFilterProperty;
Expand Down
Loading
Loading