Skip to content

Commit

Permalink
feat: Enum tokens in property filter
Browse files Browse the repository at this point in the history
  • Loading branch information
pan-kot committed Oct 17, 2024
1 parent a0670ee commit 1107f2c
Show file tree
Hide file tree
Showing 21 changed files with 899 additions and 167 deletions.
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),
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(', ') : '';
}
27 changes: 23 additions & 4 deletions pages/property-filter/property-filter-editor-permutations.page.tsx
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
36 changes: 18 additions & 18 deletions src/button-dropdown/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -230,9 +230,7 @@ const InternalButtonDropdown = React.forwardRef(
{text}
</InternalButton>
);
trigger = showMainActionOnly ? (
<div className={styles['split-trigger']}>{mainActionButton}</div>
) : (
trigger = (
<div role="group" aria-label={ariaLabel} className={styles['split-trigger-wrapper']}>
<div
className={clsx(
Expand All @@ -256,21 +254,23 @@ const InternalButtonDropdown = React.forwardRef(
>
{mainActionButton}
</div>
<div
className={clsx(
styles['trigger-item'],
styles['dropdown-trigger'],
isVisualRefresh && styles['visual-refresh'],
styles[`variant-${variant}`],
baseTriggerProps.disabled && styles.disabled,
baseTriggerProps.loading && styles.loading
)}
{...getAnalyticsMetadataAttribute(analyticsMetadata)}
>
<InternalButton ref={triggerRef} {...baseTriggerProps} __emitPerformanceMarks={false}>
{children}
</InternalButton>
</div>
{!showMainActionOnly && (
<div
className={clsx(
styles['trigger-item'],
styles['dropdown-trigger'],
isVisualRefresh && styles['visual-refresh'],
styles[`variant-${variant}`],
baseTriggerProps.disabled && styles.disabled,
baseTriggerProps.loading && styles.loading
)}
{...getAnalyticsMetadataAttribute(analyticsMetadata)}
>
<InternalButton ref={triggerRef} {...baseTriggerProps} __emitPerformanceMarks={false}>
{children}
</InternalButton>
</div>
)}
</div>
);
} else {
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 @@ -12,7 +12,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 @@ -72,11 +71,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"
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
Loading

0 comments on commit 1107f2c

Please sign in to comment.