Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Property filter enum tokens #2739

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
74 changes: 49 additions & 25 deletions pages/property-filter/common-props.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,7 @@ import React from 'react';
import { Badge, SpaceBetween } from '~components';
import { PropertyFilterProps } from '~components/property-filter';

import {
DateForm,
DateTimeForm,
DateTimeFormLegacy,
formatDateTime,
formatOwners,
OwnerMultiSelectForm,
YesNoForm,
yesNoFormat,
} from './custom-forms';
import { DateForm, DateTimeForm, DateTimeFormLegacy, formatDateTime, YesNoForm, yesNoFormat } from './custom-forms';
import { states, TableItem } from './table.data';

const getStateLabel = (value: TableItem['state'], fallback = 'Invalid value') =>
Expand All @@ -34,7 +25,8 @@ export const columnDefinitions = [
sortingField: 'state',
header: 'State',
type: 'enum',
getLabel: getStateLabel,
getLabel: (value: any) =>
Array.isArray(value) ? value.map(v => getStateLabel(v)).join(', ') : getStateLabel(value, value),
jperals marked this conversation as resolved.
Show resolved Hide resolved
propertyLabel: 'State',
cell: (item: TableItem) => getStateLabel(item.state),
},
Expand Down Expand Up @@ -259,7 +251,20 @@ export const filteringProperties: readonly PropertyFilterProps.FilteringProperty
let groupValuesLabel = `${def.propertyLabel} values`;

if (def.type === 'enum') {
operators = ['=', '!='].map(operator => ({ operator, format: def.getLabel }));
operators = [
...['=', '!='].map(operator => ({ operator, format: def.getLabel, tokenType: 'enum' })),
...[':', '!:'].map(operator => ({ operator, format: def.getLabel, tokenType: 'value' })),
];
}
if (def.id === 'tags') {
const format = (value: string[]) =>
value.length <= 5 ? value.join(', ') : [...value.slice(0, 5), `${value.length - 5} more`].join(', ');
operators = [
{ operator: '=', tokenType: 'enum', format, match: (v: unknown[], t: unknown[]) => checkArrayMatches(v, t) },
{ operator: '!=', tokenType: 'enum', format, match: (v: unknown[], t: unknown[]) => !checkArrayMatches(v, t) },
{ operator: ':', tokenType: 'enum', format, match: (v: unknown[], t: unknown[]) => checkArrayContains(v, t) },
{ operator: '!:', tokenType: 'enum', format, match: (v: unknown[], t: unknown[]) => !checkArrayContains(v, t) },
];
}

if (def.type === 'text') {
Expand Down Expand Up @@ -302,19 +307,6 @@ export const filteringProperties: readonly PropertyFilterProps.FilteringProperty
];
}

// This is not recommended as it nests
if (def.id === 'owner') {
operators = [
{
operator: '=',
form: OwnerMultiSelectForm,
format: formatOwners,
match: (itemValue: string, tokenValue: string[]) =>
Array.isArray(tokenValue) && tokenValue.some(value => itemValue === value),
},
];
}

return {
key: def.id,
operators: operators,
Expand All @@ -323,3 +315,35 @@ export const filteringProperties: readonly PropertyFilterProps.FilteringProperty
groupValuesLabel,
};
});

function checkArrayMatches(value: unknown[], token: unknown[]) {
if (!Array.isArray(value) || !Array.isArray(token) || value.length !== token.length) {
return false;
}
const valuesMap = value.reduce<Map<unknown, number>>(
(map, value) => map.set(value, (map.get(value) ?? 0) + 1),
new Map()
);
for (const tokenEntry of token) {
const count = valuesMap.get(tokenEntry);
if (count) {
count === 1 ? valuesMap.delete(tokenEntry) : valuesMap.set(tokenEntry, count - 1);
} else {
return false;
}
}
return valuesMap.size === 0;
}

function checkArrayContains(value: unknown[], token: unknown[]) {
if (!Array.isArray(value) || !Array.isArray(token)) {
return false;
}
const valuesSet = new Set(value);
for (const tokenEntry of token) {
if (!valuesSet.has(tokenEntry)) {
return false;
}
}
return true;
}
59 changes: 0 additions & 59 deletions pages/property-filter/custom-forms.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,8 @@ import React, { useEffect, useState } from 'react';
import { DatePicker, FormField, RadioGroup, TimeInput, TimeInputProps } from '~components';
import Calendar, { CalendarProps } from '~components/calendar';
import DateInput from '~components/date-input';
import EmbeddedMultiselect from '~components/multiselect/embedded';
import InternalMultiselect from '~components/multiselect/internal';
import { ExtendedOperatorFormProps } from '~components/property-filter/interfaces';

import { allItems } from './table.data';

import styles from './custom-forms.scss';

export function YesNoForm({ value, onChange }: ExtendedOperatorFormProps<boolean>) {
Expand Down Expand Up @@ -218,58 +214,3 @@ function formatTimezoneOffset(isoDate: string, offsetInMinutes?: number) {
.padStart(2, '0');
return `${sign}${hoursOffset}:${minuteOffset}`;
}

const allOwners = [...new Set(allItems.map(({ owner }) => owner))];

export function OwnerMultiSelectForm({ value, onChange, filter }: ExtendedOperatorFormProps<string[]>) {
value = value && Array.isArray(value) ? value : [];

if (typeof filter !== 'undefined') {
return (
<EmbeddedMultiselect
options={allOwners.map(owner => ({ value: owner, label: owner }))}
selectedOptions={value.map(owner => ({ value: owner, label: owner })) ?? []}
onChange={event =>
onChange(
event.detail.selectedOptions
.map(({ value }) => value)
.filter((value): value is string => typeof value !== 'undefined')
)
}
filteringText={filter}
statusType="finished"
filteringType="auto"
empty="No options available"
noMatch="No options matched"
/>
);
}

return (
<div className={styles['multiselect-form']}>
<FormField stretch={true}>
<InternalMultiselect
options={allOwners.map(owner => ({ value: owner, label: owner }))}
selectedOptions={value.map(owner => ({ value: owner, label: owner })) ?? []}
onChange={event =>
onChange(
event.detail.selectedOptions
.map(({ value }) => value)
.filter((value): value is string => typeof value !== 'undefined')
)
}
statusType="finished"
filteringType="none"
expandToViewport={true}
keepOpen={true}
hideTokens={false}
inlineTokens={true}
/>
</FormField>
</div>
);
}

export function formatOwners(owners: string[]) {
return owners && Array.isArray(owners) ? owners.join(', ') : '';
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ const nameProperty: InternalFilteringProperty = {
groupValuesLabel: 'Name values',
operators: ['=', '!='],
defaultOperator: '=',
getTokenType: () => 'value',
getValueFormatter: () => null,
getValueFormRenderer: () => null,
externalProperty,
};

const stateProperty: InternalFilteringProperty = {
propertyKey: 'state',
propertyLabel: 'State',
groupValuesLabel: 'State values',
operators: ['=', '!='],
defaultOperator: '=',
getTokenType: () => 'enum',
getValueFormatter: () => null,
getValueFormRenderer: () => null,
externalProperty,
Expand All @@ -43,6 +56,7 @@ const dateProperty: InternalFilteringProperty = {
groupValuesLabel: 'Date values',
operators: ['=', '!='],
defaultOperator: '=',
getTokenType: () => 'value',
getValueFormatter: () => (value: Date) => (value ? format(value, 'yyyy-MM-dd') : ''),
getValueFormRenderer:
() =>
Expand All @@ -60,6 +74,7 @@ const dateTimeProperty: InternalFilteringProperty = {
groupValuesLabel: 'Date time values',
operators: ['=', '!='],
defaultOperator: '=',
getTokenType: () => 'value',
getValueFormatter: () => (value: Date) => (value ? format(value, 'yyyy-MM-dd hh:mm') : ''),
getValueFormRenderer:
() =>
Expand All @@ -86,7 +101,11 @@ const defaultProps: Omit<TokenEditorProps, 'i18nStrings'> = {
defaultOperator: ':',
},
filteringProperties: [nameProperty, dateProperty],
filteringOptions: [],
filteringOptions: [
{ property: stateProperty, value: 'Happy', label: 'Happy' },
{ property: stateProperty, value: 'Healthy', label: 'Healthy' },
{ property: stateProperty, value: 'Wealthy', label: 'Wealthy' },
],
onSubmit: () => {},
onDismiss: () => {},
tokensToCapture: [],
Expand All @@ -96,7 +115,7 @@ const defaultProps: Omit<TokenEditorProps, 'i18nStrings'> = {
onChangeTempGroup: () => {},
};

const tokenPermutations = createPermutations<Partial<TokenEditorProps>>([
const editorPermutations = createPermutations<Partial<TokenEditorProps>>([
// Single name property
{
tempGroup: [[{ property: nameProperty, operator: '=', value: 'John' }]],
Expand All @@ -116,7 +135,7 @@ const tokenPermutations = createPermutations<Partial<TokenEditorProps>>([
[
{ property: nameProperty, operator: '=', value: 'John' },
{ property: dateTimeProperty, operator: '=', value: new Date('2020-01-01T01:00') },
{ property: nameProperty, operator: '=', value: 'Jack' },
{ property: stateProperty, operator: '=', value: ['Happy', 'Healthy'] },
],
],
tokensToCapture: [
Expand Down Expand Up @@ -160,7 +179,7 @@ export default function () {
<h1>Property filter editor permutations</h1>
<ScreenshotArea disableAnimations={true}>
<PermutationsView
permutations={tokenPermutations}
permutations={editorPermutations}
render={permutation => <TokenEditorStateful {...defaultProps} {...permutation} />}
/>
</ScreenshotArea>
Expand Down
8 changes: 7 additions & 1 deletion pages/property-filter/token-editor.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,17 @@ const filteringProperties: readonly PropertyFilterProps.FilteringProperty[] = co
groupValuesLabel: `${def.propertyLabel} values`,
}));

const filteringOptions: readonly PropertyFilterProps.FilteringOption[] = [
{ propertyKey: 'state', value: 'Stopping' },
{ propertyKey: 'state', value: 'Stopped' },
{ propertyKey: 'state', value: 'Running' },
];

const commonProps = {
...labels,
onChange: () => {},
filteringProperties,
filteringOptions: [],
filteringOptions,
i18nStrings,
countText: '5 matches',
disableFreeTextFiltering: false,
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/__snapshots__/documenter.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -12667,7 +12667,7 @@ The \`operation\` property has two valid values: "and", "or", and controls the j
The \`tokens\` property is an array of objects that will be displayed to the user beneath the filtering input. When \`enableTokenGroups=true\`, the
\`tokenGroups\` property is used instead, which supports nested tokens.
Each token has the following properties:
* value [string]: The string value of the token to be used as a filter.
* value [unknown]: The value of the token to be used as a filter. Can be null or string for default tokens, string[] for enum tokens, and anything for tokens with custom forms.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to double-check that this API change is OK (the doc text update suggests yes), and this will not cause issues to existing customers (do we want to run a dry run?)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did make a dry-run for the type update in collection hooks. In this PR no type was updated, but will do a dry-run as well, just in case.

The value has been essentially unknown since introduction of custom forms. The current PR should have no impact to the existing customers but those making something with the query and wanting to adopt the feature would need to ensure the type is correctly handled at runtime.

* propertyKey [string]: The key of the corresponding property in filteringProperties.
* operator ['<' | '<=' | '>' | '>=' | ':' | '!:' | '=' | '!=' | '^' | '!^']: The operator which indicates how to filter the dataset using this token.
",
Expand Down
6 changes: 0 additions & 6 deletions src/multiselect/__tests__/multiselect-embedded.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import EmbeddedMultiselect, { EmbeddedMultiselectProps } from '../../../lib/comp

import dropdownFooterStyles from '../../../lib/components/internal/components/dropdown-footer/styles.css.js';
import selectableItemsStyles from '../../../lib/components/internal/components/selectable-item/styles.css.js';
import multiselectStyles from '../../../lib/components/multiselect/styles.css.js';

const defaultOptions: MultiselectProps.Options = [
{ label: 'First', value: '1' },
Expand Down Expand Up @@ -87,11 +86,6 @@ test.each([
test('ARIA labels', () => {
renderComponent({ ariaLabel: 'My list', controlId: 'list-control', statusType: 'loading' });

const group = createWrapper().findByClassName(multiselectStyles.embedded)!.getElement();
expect(group).toHaveAttribute('role', 'group');
expect(group).toHaveAccessibleName('My list');
expect(group).toHaveAccessibleDescription('Loading...');

const list = createWrapper().find('ul')!.getElement();
expect(list).toHaveAttribute('role', 'listbox');
expect(list).toHaveAccessibleName('My list Input name');
Expand Down
12 changes: 2 additions & 10 deletions src/multiselect/embedded.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,13 @@ const EmbeddedMultiselect = React.forwardRef(
const formFieldContext = useFormFieldContext(restProps);
const ariaLabelId = useUniqueId('multiselect-ariaLabel-');
const footerId = useUniqueId('multiselect-footer-');
const selfControlId = useUniqueId('multiselect-trigger-');
const controlId = formFieldContext.controlId ?? selfControlId;

const multiselectProps = useMultiselect({
options,
selectedOptions,
filteringType,
disabled: false,
deselectAriaLabel,
controlId,
controlId: formFieldContext.controlId,
ariaLabelId,
footerId,
filteringValue: filteringText,
Expand All @@ -77,12 +74,7 @@ const EmbeddedMultiselect = React.forwardRef(
const status = multiselectProps.dropdownStatus;

return (
<div
role="group"
pan-kot marked this conversation as resolved.
Show resolved Hide resolved
className={styles.embedded}
aria-labelledby={ariaLabelId}
aria-describedby={status.content ? footerId : undefined}
>
<div className={styles.embedded}>
<ListComponent
menuProps={multiselectProps.getMenuProps()}
getOptionProps={multiselectProps.getOptionProps}
Expand Down
2 changes: 1 addition & 1 deletion src/multiselect/use-multiselect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ type UseMultiselectOptions = SomeRequired<
| 'selectedAriaLabel'
> &
DropdownStatusProps & {
controlId: string;
controlId?: string;
ariaLabelId: string;
footerId: string;
filteringValue: string;
Expand Down
18 changes: 16 additions & 2 deletions src/property-filter/__tests__/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ export const createDefaultProps = (
onChange: () => {},
query: { tokens: [], operation: 'and' },
i18nStrings,
filteringLoadingText: 'Loading status',
filteringErrorText: 'Error status',
filteringFinishedText: 'Finished status',
filteringRecoveryText: 'Retry',
});

export function toInternalProperties(properties: FilteringProperty[]): InternalFilteringProperty[] {
Expand All @@ -124,13 +128,23 @@ export function toInternalProperties(properties: FilteringProperty[]): InternalF
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,
}));
}

export function StatefulPropertyFilter(props: Omit<PropertyFilterProps, 'onChange'>) {
export function StatefulPropertyFilter(props: PropertyFilterProps) {
const [query, setQuery] = useState<PropertyFilterProps.Query>(props.query);
return <PropertyFilter {...props} query={query} onChange={e => setQuery(e.detail)} />;
return (
<PropertyFilter
{...props}
query={query}
onChange={event => {
props.onChange(event);
setQuery(event.detail);
}}
/>
);
}
Loading
Loading