Skip to content

Commit

Permalink
wip: Property filter uses internal embedded multiselect
Browse files Browse the repository at this point in the history
  • Loading branch information
pan-kot committed Oct 13, 2024
1 parent 20be751 commit bbd5c55
Show file tree
Hide file tree
Showing 20 changed files with 864 additions and 139 deletions.
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
"start:watch": "gulp watch",
"start:dev": "cross-env NODE_ENV=development webpack serve --config pages/webpack.config.js",
"start:integ": "cross-env NODE_ENV=development webpack serve --config pages/webpack.config.integ.js",
"prepare": "husky"
"prepare": "husky",
"postinstall": "node ./scripts/install-peer-dependency.js collection-hooks:feat-property-filter-enum-props-3"
},
"dependencies": {
"@cloudscape-design/collection-hooks": "^1.0.0",
Expand Down
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(', ') : '';
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const nameProperty: InternalFilteringProperty = {
groupValuesLabel: 'Name values',
operators: ['=', '!='],
defaultOperator: '=',
getTokenType: () => 'value',
getValueFormatter: () => null,
getValueFormRenderer: () => null,
externalProperty,
Expand All @@ -43,6 +44,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 +62,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 Down
59 changes: 59 additions & 0 deletions scripts/install-peer-dependency.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env node
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

// Can be used in postinstall script like so:
// "postinstall": "node ./scripts/install-peer-dependency.js collection-hooks:property-filter-token-groups"
// where "collection-hooks" is the package to fetch and "property-filter-token-groups" is the branch name in GitHub.

const { execSync } = require('child_process');
const path = require('path');
const os = require('os');

const args = process.argv.slice(2);
if (args.length < 1) {
console.error('Usage: install-peer-dependency.js <package-name>:<target-branch>');
process.exit(1);
}
const [packageName, targetBranch] = args[0].split(':');
const targetRepository = `https://github.com/cloudscape-design/${packageName}.git`;
const nodeModulesPackagePath = path.join(process.cwd(), 'node_modules', '@cloudscape-design', packageName);
const tempDir = path.join(os.tmpdir(), `temp-${packageName}`);

// Clone the repository and checkout the branch
console.log(`Cloning ${packageName}:${targetBranch}...`);
execCommand(`git clone ${targetRepository} ${tempDir}`);
process.chdir(tempDir);
execCommand(`git checkout ${targetBranch}`);

// Install dependencies and build
console.log(`Installing dependencies and building ${packageName}...`);
execCommand('npm install');
execCommand('npm run build');

// Remove existing peer dependency in node_modules
console.log(`Removing existing ${packageName} from node_modules...`);
execCommand(`rm -rf ${nodeModulesPackagePath}`);

// Copy built peer dependency to node_modules
console.log(`Copying build ${targetRepository} to node_modules...`);
execCommand(`mkdir -p ${nodeModulesPackagePath}`);
execCommand(`cp -R ${tempDir}/lib/* ${nodeModulesPackagePath}`);

// Clean up
console.log('Cleaning up...');
execCommand(`rm -rf ${tempDir}`);

console.log(`${packageName} has been successfully installed from branch ${targetBranch}!`);

function execCommand(command, options = {}) {
try {
execSync(command, { stdio: 'inherit', ...options });
} catch (error) {
console.error(`Error executing command: ${command}`);
console.error(`Error message: ${error.message}`);
console.error(`Stdout: ${error.stdout && error.stdout.toString()}`);
console.error(`Stderr: ${error.stderr && error.stderr.toString()}`);
throw error;
}
}
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
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

0 comments on commit bbd5c55

Please sign in to comment.