From 9b9c3dd2a98ec508f8eb1c18a7eac43448be6d21 Mon Sep 17 00:00:00 2001 From: Andrei Zhaleznichenka Date: Mon, 7 Oct 2024 13:39:37 +0200 Subject: [PATCH] wip: Property filter uses internal embedded multiselect --- package-lock.json | 1 + package.json | 3 +- pages/common/options-loader.ts | 7 +- .../multiselect.async.example.page.tsx | 2 +- .../async-loading.integ.page.tsx | 8 +- pages/property-filter/common-props.tsx | 77 ++- pages/property-filter/custom-forms.tsx | 59 -- ...operty-filter-editor-permutations.page.tsx | 3 + ...plit-panel-app-layout-integration.page.tsx | 117 +++- scripts/install-peer-dependency.js | 59 ++ src/multiselect/styles.scss | 5 +- src/property-filter/__tests__/common.tsx | 18 +- .../property-filter-enum-tokens.test.tsx | 517 ++++++++++++++++++ ...roperty-filter-extended-operators.test.tsx | 6 +- .../property-filter.filtering-input.test.tsx | 2 +- .../__tests__/property-filter.test.tsx | 3 +- src/property-filter/i18n-utils.ts | 5 +- src/property-filter/interfaces.ts | 3 + src/property-filter/internal.tsx | 21 +- src/property-filter/property-editor.tsx | 79 ++- src/property-filter/styles.scss | 10 +- src/property-filter/test-classes/styles.scss | 8 + src/property-filter/token-editor-inputs.tsx | 64 ++- src/property-filter/token-editor.tsx | 6 +- src/property-filter/utils.ts | 14 +- src/test-utils/dom/property-filter/index.ts | 8 + 26 files changed, 960 insertions(+), 145 deletions(-) create mode 100644 scripts/install-peer-dependency.js create mode 100644 src/property-filter/__tests__/property-filter-enum-tokens.test.tsx diff --git a/package-lock.json b/package-lock.json index cd4d54f5d0..14fdc25dba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "@cloudscape-design/components", "version": "3.0.0", + "hasInstallScript": true, "dependencies": { "@cloudscape-design/collection-hooks": "^1.0.0", "@cloudscape-design/component-toolkit": "^1.0.0-beta", diff --git a/package.json b/package.json index 6b1c0d34de..e48c30f656 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pages/common/options-loader.ts b/pages/common/options-loader.ts index 7f6c4aa8d3..f57f514cd3 100644 --- a/pages/common/options-loader.ts +++ b/pages/common/options-loader.ts @@ -64,8 +64,9 @@ export function useOptionsLoader({ pageSize = 25, timeout = 1000, randomEr if (randomErrors && Math.random() < 0.3) { reject(); } else { - const nextItems = sourceItems.slice(pageNumber * pageSize, (pageNumber + 1) * pageSize); - resolve({ items: nextItems, hasNextPage: items.length + nextItems.length < sourceItems.length }); + const newItems = sourceItems.slice(pageNumber * pageSize, (pageNumber + 1) * pageSize); + const nextItems = [...items, ...newItems]; + resolve({ items: nextItems, hasNextPage: nextItems.length < sourceItems.length }); } }, timeout) ); @@ -100,7 +101,7 @@ export function useOptionsLoader({ pageSize = 25, timeout = 1000, randomEr request.promise .then(response => { if (!request.cancelled) { - setItems(prev => [...prev, ...(response.items as Item[])]); + setItems(response.items as Item[]); setStatus(response.hasNextPage ? 'pending' : 'finished'); } }) diff --git a/pages/multiselect/multiselect.async.example.page.tsx b/pages/multiselect/multiselect.async.example.page.tsx index 9f3fe2976b..911ed72bb1 100644 --- a/pages/multiselect/multiselect.async.example.page.tsx +++ b/pages/multiselect/multiselect.async.example.page.tsx @@ -106,7 +106,7 @@ function EmbeddedMultiselectIntegration(props: EmbeddedMultiselectProps) { filteringPlaceholder="Find security group" /> -
+
diff --git a/pages/property-filter/async-loading.integ.page.tsx b/pages/property-filter/async-loading.integ.page.tsx index 761a8b1a60..2dd2b4ac35 100644 --- a/pages/property-filter/async-loading.integ.page.tsx +++ b/pages/property-filter/async-loading.integ.page.tsx @@ -88,10 +88,10 @@ export default function () { filteringProperties={filteringProperties} filteringOptions={optionsLoader.items} filteringStatusType={status} - filteringLoadingText={'loading text'} - filteringErrorText={'error text'} - filteringRecoveryText={'recovery text'} - filteringFinishedText={'finished text'} + filteringLoadingText="loading text" + filteringErrorText="error text" + filteringRecoveryText="recovery text" + filteringFinishedText="finished text" onLoadItems={handleLoadItems} asyncProperties={urlParams.asyncProperties} virtualScroll={true} diff --git a/pages/property-filter/common-props.tsx b/pages/property-filter/common-props.tsx index 542f51a5de..7c7a746b67 100644 --- a/pages/property-filter/common-props.tsx +++ b/pages/property-filter/common-props.tsx @@ -5,19 +5,11 @@ 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']) => (value !== undefined && states[value]) || 'Unknown'; +const getStateLabel = (value: TableItem['state'], fallback = 'Invalid value') => + (value !== undefined && states[value]) || fallback; export const columnDefinitions = [ { @@ -33,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), }, @@ -73,7 +66,7 @@ export const columnDefinitions = [ id: 'owner', sortingField: 'owner', header: 'Owner', - type: 'text', + type: 'enum', propertyLabel: 'Owner', cell: (item: TableItem) => item.owner, }, @@ -254,7 +247,18 @@ 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') { + operators = [ + { operator: '=', tokenType: 'enum', match: (v: unknown[], t: unknown[]) => checkArrayMatches(v, t) }, + { operator: '!=', tokenType: 'enum', match: (v: unknown[], t: unknown[]) => !checkArrayMatches(v, t) }, + { operator: ':', tokenType: 'enum', match: (v: unknown[], t: unknown[]) => checkArrayContains(v, t) }, + { operator: '!:', tokenType: 'enum', match: (v: unknown[], t: unknown[]) => !checkArrayContains(v, t) }, + ]; } if (def.type === 'text') { @@ -297,19 +301,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, @@ -318,3 +309,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, 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; +} diff --git a/pages/property-filter/custom-forms.tsx b/pages/property-filter/custom-forms.tsx index f4bb0fea24..be10da5040 100644 --- a/pages/property-filter/custom-forms.tsx +++ b/pages/property-filter/custom-forms.tsx @@ -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) { @@ -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) { - value = value && Array.isArray(value) ? value : []; - - if (typeof filter !== 'undefined') { - return ( - ({ 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 ( -
- - ({ 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} - /> - -
- ); -} - -export function formatOwners(owners: string[]) { - return owners && Array.isArray(owners) ? owners.join(', ') : ''; -} diff --git a/pages/property-filter/property-filter-editor-permutations.page.tsx b/pages/property-filter/property-filter-editor-permutations.page.tsx index e4035724ac..8b8b8b6ca5 100644 --- a/pages/property-filter/property-filter-editor-permutations.page.tsx +++ b/pages/property-filter/property-filter-editor-permutations.page.tsx @@ -32,6 +32,7 @@ const nameProperty: InternalFilteringProperty = { groupValuesLabel: 'Name values', operators: ['=', '!='], defaultOperator: '=', + getTokenType: () => 'value', getValueFormatter: () => null, getValueFormRenderer: () => null, externalProperty, @@ -43,6 +44,7 @@ const dateProperty: InternalFilteringProperty = { groupValuesLabel: 'Date values', operators: ['=', '!='], defaultOperator: '=', + getTokenType: () => 'value', getValueFormatter: () => (value: Date) => (value ? format(value, 'yyyy-MM-dd') : ''), getValueFormRenderer: () => @@ -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: () => diff --git a/pages/property-filter/split-panel-app-layout-integration.page.tsx b/pages/property-filter/split-panel-app-layout-integration.page.tsx index 1a10c02b60..bd5b747375 100644 --- a/pages/property-filter/split-panel-app-layout-integration.page.tsx +++ b/pages/property-filter/split-panel-app-layout-integration.page.tsx @@ -4,7 +4,18 @@ import React, { useContext, useState } from 'react'; import { useCollection } from '@cloudscape-design/collection-hooks'; -import { AppLayout, Box, Button, Checkbox, Header, PropertyFilter, SpaceBetween, SplitPanel, Table } from '~components'; +import { + AppLayout, + Box, + Button, + Checkbox, + Header, + PropertyFilter, + PropertyFilterProps, + SpaceBetween, + SplitPanel, + Table, +} from '~components'; import I18nProvider from '~components/i18n'; import messages from '~components/i18n/messages/all.en'; @@ -12,25 +23,37 @@ import AppContext, { AppContextType } from '../app/app-context'; import { Breadcrumbs, Navigation, Tools } from '../app-layout/utils/content-blocks'; import appLayoutLabels from '../app-layout/utils/labels'; import * as toolsContent from '../app-layout/utils/tools-content'; +import { useOptionsLoader } from '../common/options-loader'; import ScreenshotArea from '../utils/screenshot-area'; import { columnDefinitions, filteringProperties, labels } from './common-props'; import { allItems, states, TableItem } from './table.data'; type PageContext = React.Context< AppContextType<{ + virtualScroll?: boolean; + expandToViewport?: boolean; enableTokenGroups?: boolean; disableFreeTextFiltering?: boolean; hideOperations?: boolean; + asyncOptions?: boolean; + emptyOptions?: boolean; }> >; export default function () { const { - urlParams: { enableTokenGroups = true, disableFreeTextFiltering = false, hideOperations = false }, + urlParams: { + virtualScroll = true, + expandToViewport = true, + enableTokenGroups = true, + disableFreeTextFiltering = false, + hideOperations = false, + asyncOptions = false, + emptyOptions = false, + }, setUrlParams, } = useContext(AppContext as PageContext); - const [splitPanelOpen, setSplitPanelOpen] = useState(true); const { items, collectionProps, actions, propertyFilterProps } = useCollection(allItems, { propertyFiltering: { empty: 'empty', @@ -55,12 +78,57 @@ export default function () { sorting: {}, }); - const filteringOptions = propertyFilterProps.filteringOptions.map(option => { - if (option.propertyKey === 'state') { - option.label = states[option.value]; + let filteringOptions = propertyFilterProps.filteringOptions + .map(option => { + if (option.propertyKey === 'state') { + option.label = states[option.value]; + } + return option; + }) + .filter(option => option.propertyKey !== 'tags') + .filter(option => option.propertyKey !== 'averagelatency'); + + const allTags = new Set(); + for (const item of allItems) { + for (const tag of item.tags ?? []) { + allTags.add(tag); } - return option; - }); + } + for (const tag of allTags) { + filteringOptions.push({ propertyKey: 'tags', value: tag }); + } + + if (emptyOptions) { + filteringOptions = []; + } + + const [splitPanelOpen, setSplitPanelOpen] = useState(true); + const [filteringText, setFilteringText] = useState(''); + const optionsLoader = useOptionsLoader({ pageSize: 15, timeout: 1000 }); + const handleLoadItems = ({ + detail: { filteringProperty, filteringText, firstPage }, + }: { + detail: PropertyFilterProps.LoadItemsDetail; + }) => { + if (filteringProperty) { + const sourceItems = filteringOptions.filter(option => option.propertyKey === filteringProperty.key); + optionsLoader.fetchItems({ sourceItems, filteringText, firstPage }); + setFilteringText(filteringText); + } else { + setFilteringText(''); + } + }; + const asyncProps = asyncOptions + ? { + asyncProperties: false, + filteringStatusType: optionsLoader.status, + onLoadItems: handleLoadItems, + filteringLoadingText: 'Loading options', + filteringErrorText: 'Error fetching results.', + filteringRecoveryText: 'Retry', + filteringFinishedText: filteringText ? `End of "${filteringText}" results` : 'End of all results', + } + : {}; return ( @@ -89,6 +157,18 @@ export default function () { }} > + setUrlParams({ virtualScroll: detail.checked })} + > + virtualScroll + + setUrlParams({ expandToViewport: detail.checked })} + > + expandToViewport + setUrlParams({ enableTokenGroups: detail.checked })} @@ -107,6 +187,18 @@ export default function () { > hideOperations + setUrlParams({ asyncOptions: detail.checked })} + > + asyncOptions + + setUrlParams({ emptyOptions: detail.checked })} + > + Empty options + } @@ -121,14 +213,15 @@ export default function () { } columnDefinitions={columnDefinitions} diff --git a/scripts/install-peer-dependency.js b/scripts/install-peer-dependency.js new file mode 100644 index 0000000000..0eef303f3f --- /dev/null +++ b/scripts/install-peer-dependency.js @@ -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 :'); + 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; + } +} diff --git a/src/multiselect/styles.scss b/src/multiselect/styles.scss index 7b2f0f670b..764ce004df 100644 --- a/src/multiselect/styles.scss +++ b/src/multiselect/styles.scss @@ -12,10 +12,7 @@ .embedded { @include styles.styles-reset; - - display: flex; - flex-direction: column; - inline-size: 100%; + display: contents; } .tokens { diff --git a/src/property-filter/__tests__/common.tsx b/src/property-filter/__tests__/common.tsx index ad997a9ab1..94f4ae2fab 100644 --- a/src/property-filter/__tests__/common.tsx +++ b/src/property-filter/__tests__/common.tsx @@ -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[] { @@ -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) { +export function StatefulPropertyFilter(props: PropertyFilterProps) { const [query, setQuery] = useState(props.query); - return setQuery(e.detail)} />; + return ( + { + props.onChange(event); + setQuery(event.detail); + }} + /> + ); } diff --git a/src/property-filter/__tests__/property-filter-enum-tokens.test.tsx b/src/property-filter/__tests__/property-filter-enum-tokens.test.tsx new file mode 100644 index 0000000000..bb4a2ec463 --- /dev/null +++ b/src/property-filter/__tests__/property-filter-enum-tokens.test.tsx @@ -0,0 +1,517 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import { KeyCode } from '@cloudscape-design/test-utils-core/utils'; + +import '../../__a11y__/to-validate-a11y'; +import { + FilteringOption, + FilteringProperty, + PropertyFilterProps, +} from '../../../lib/components/property-filter/interfaces'; +import createWrapper from '../../../lib/components/test-utils/dom'; +import { createDefaultProps, StatefulPropertyFilter } from './common'; + +const states: Record = { + 0: 'Stopped', + 1: 'Stopping', + 2: 'Running', +}; +const formatState = (value: unknown, fallback = 'Invalid value') => { + function formatOne(valueEntry: unknown) { + return typeof valueEntry === 'string' ? states[valueEntry] || fallback : fallback; + } + if (Array.isArray(value)) { + return value.map(formatOne).join(', '); + } + return formatOne(value); +}; + +const filteringProperties: readonly FilteringProperty[] = [ + { + key: 'state', + propertyLabel: 'State', + operators: [ + { operator: '=', format: formatState, tokenType: 'enum' }, + { operator: '!=', format: formatState, tokenType: 'enum' }, + { operator: ':', format: value => formatState(value, value) }, + { operator: '!:', format: value => formatState(value, value) }, + ], + groupValuesLabel: 'State values', + }, + { + key: 'tags', + propertyLabel: 'Tags', + operators: [ + { operator: '=', tokenType: 'enum' }, + { operator: '!=', tokenType: 'enum' }, + { operator: ':', tokenType: 'enum' }, + { operator: '!:', tokenType: 'enum' }, + ], + groupValuesLabel: 'Tags values', + }, +]; + +const filteringOptions: readonly FilteringOption[] = [ + { propertyKey: 'state', value: '0', label: formatState('0') }, + { propertyKey: 'state', value: '1', label: formatState('1') }, + { propertyKey: 'state', value: '2', label: formatState('2') }, +]; + +const defaultProps = createDefaultProps(filteringProperties, filteringOptions); + +const renderComponent = (props?: Partial) => { + const onChange = jest.fn(); + const onLoadItems = jest.fn(); + const { container } = render( + + ); + const wrapper = createWrapper(container).findPropertyFilter()!; + const openEditor = () => wrapper.findTokens()[0].findLabel().click(); + const findEditor = () => wrapper.findTokens()[0].findEditorDropdown()!; + const getTokensContent = () => wrapper.findTokens().map(w => w.getElement().textContent); + const getOptionsContent = () => + wrapper + .findDropdown() + .findOptions() + .map(w => w.getElement().textContent); + const findEditorMultiselect = () => findEditor().findValueField().findControl()!.findMultiselect()!; + const getEditorOptionsContent = () => + findEditorMultiselect() + .findDropdown() + .findOptions() + .map(w => w.getElement().textContent); + return { + container, + wrapper, + openEditor, + findEditor, + getTokensContent, + getOptionsContent, + findEditorMultiselect, + getEditorOptionsContent, + onChange, + onLoadItems, + }; +}; + +describe('sync token creation', () => { + test('creates a value token for state', () => { + const { wrapper, getTokensContent, onChange } = renderComponent(); + + wrapper.focus(); + wrapper.setInputValue('state : '); + wrapper.selectSuggestionByValue('State : 0'); + + expect(getTokensContent()).toEqual(['State : Stopped']); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { operation: 'and', tokens: [{ propertyKey: 'state', operator: ':', value: '0' }] }, + }) + ); + }); + + test('creates an enum token for state by selecting a suggested value', () => { + const { wrapper, getTokensContent, onChange } = renderComponent(); + + wrapper.focus(); + wrapper.setInputValue('Sto'); + wrapper.selectSuggestionByValue('State = 1'); + + expect(getTokensContent()).toEqual(['State = Stopping']); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { operation: 'and', tokens: [{ propertyKey: 'state', operator: '=', value: ['1'] }] }, + }) + ); + }); + + test('creates an enum token for state by submitting value with Enter', () => { + const { wrapper, getTokensContent, onChange } = renderComponent(); + + wrapper.focus(); + wrapper.setInputValue('state = Running'); + wrapper.findNativeInput().keydown(KeyCode.enter); + + expect(getTokensContent()).toEqual(['State = Running']); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { operation: 'and', tokens: [{ propertyKey: 'state', operator: '=', value: ['2'] }] }, + }) + ); + }); + + test('creates an enum token for state by submitting non-matched value with Enter', () => { + const { wrapper, getTokensContent, onChange } = renderComponent(); + + wrapper.focus(); + wrapper.setInputValue('state = Unmatched'); + wrapper.findNativeInput().keydown(KeyCode.enter); + + expect(getTokensContent()).toEqual(['State = Invalid value']); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { operation: 'and', tokens: [{ propertyKey: 'state', operator: '=', value: ['Unmatched'] }] }, + }) + ); + }); + + test('creates an enum token for state by submitting two values with multiselect', () => { + const { wrapper, getTokensContent, onChange } = renderComponent(); + + wrapper.focus(); + wrapper.setInputValue('state = '); + wrapper.selectSuggestionByValue('0'); + wrapper.selectSuggestionByValue('1'); + wrapper.findPropertySubmitButton()!.click(); + + expect(getTokensContent()).toEqual(['State = Stopped, Stopping']); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { operation: 'and', tokens: [{ propertyKey: 'state', operator: '=', value: ['0', '1'] }] }, + }) + ); + }); + + test('filters enum token values with property filter input text', () => { + const { wrapper, getOptionsContent } = renderComponent(); + + wrapper.focus(); + wrapper.setInputValue('state = ing'); + + expect(getOptionsContent()).toEqual(['Stopping', 'Running']); + }); + + test('shows filtering empty if no enum token values are provided', () => { + const { wrapper } = renderComponent({ filteringOptions: [] }); + + wrapper.focus(); + wrapper.setInputValue('state ='); + + expect(wrapper.findStatusIndicator()!.getElement()).toHaveTextContent('Empty'); + }); + + test('shows filtering empty if no enum token values are matched', () => { + const { wrapper } = renderComponent(); + + wrapper.focus(); + wrapper.setInputValue('state = X'); + + expect(wrapper.findStatusIndicator()!.getElement()).toHaveTextContent('Empty'); + }); +}); + +describe('sync token editing', () => { + test('can replace value token with enum token', () => { + const { openEditor, findEditor, getTokensContent, onChange } = renderComponent({ + query: { operation: 'and', tokens: [{ propertyKey: 'state', operator: ':', value: '0' }] }, + }); + + openEditor(); + findEditor().findOperatorField().findControl()!.findSelect()!.openDropdown(); + findEditor().findOperatorField().findControl()!.findSelect()!.selectOptionByValue('='); + findEditor().findSubmitButton().click(); + + expect(getTokensContent()).toEqual(['State = ']); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { operation: 'and', tokens: [{ propertyKey: 'state', operator: '=', value: [] }] }, + }) + ); + }); + + test('can replace enum token with value token', () => { + const { openEditor, findEditor, getTokensContent, onChange } = renderComponent({ + query: { operation: 'and', tokens: [{ propertyKey: 'state', operator: '=', value: ['0'] }] }, + }); + + openEditor(); + findEditor().findOperatorField().findControl()!.findSelect()!.openDropdown(); + findEditor().findOperatorField().findControl()!.findSelect()!.selectOptionByValue(':'); + findEditor().findSubmitButton().click(); + + expect(getTokensContent()).toEqual(['State : ']); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { operation: 'and', tokens: [{ propertyKey: 'state', operator: ':', value: null }] }, + }) + ); + }); + + test('keeps value when changing operator so that value type preserves', () => { + const { openEditor, findEditor, getTokensContent, onChange } = renderComponent({ + query: { operation: 'and', tokens: [{ propertyKey: 'state', operator: ':', value: '0' }] }, + }); + + openEditor(); + findEditor().findOperatorField().findControl()!.findSelect()!.openDropdown(); + findEditor().findOperatorField().findControl()!.findSelect()!.selectOptionByValue('!:'); + findEditor().findSubmitButton().click(); + + expect(getTokensContent()).toEqual(['State !: Stopped']); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { operation: 'and', tokens: [{ propertyKey: 'state', operator: '!:', value: '0' }] }, + }) + ); + }); + + test('keeps value when changing operator so that enum type preserves', () => { + const { openEditor, findEditor, getTokensContent, onChange } = renderComponent({ + query: { operation: 'and', tokens: [{ propertyKey: 'state', operator: '=', value: ['0'] }] }, + }); + + openEditor(); + findEditor().findOperatorField().findControl()!.findSelect()!.openDropdown(); + findEditor().findOperatorField().findControl()!.findSelect()!.selectOptionByValue('!='); + findEditor().findSubmitButton().click(); + + expect(getTokensContent()).toEqual(['State != Stopped']); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { operation: 'and', tokens: [{ propertyKey: 'state', operator: '!=', value: ['0'] }] }, + }) + ); + }); + + test('enum token options can be filtered', () => { + const { openEditor, findEditorMultiselect, getEditorOptionsContent } = renderComponent({ + query: { operation: 'and', tokens: [{ propertyKey: 'state', operator: '=', value: ['0'] }] }, + }); + + openEditor(); + findEditorMultiselect().openDropdown(); + findEditorMultiselect().findFilteringInput()!.setInputValue('ing'); + + expect(getEditorOptionsContent()).toEqual(['Stopping', 'Running']); + }); +}); + +describe('async token creation', () => { + test('shows enum token values and a loading indicator if status type is loading', () => { + const { wrapper, getOptionsContent } = renderComponent({ filteringStatusType: 'loading' }); + + wrapper.focus(); + wrapper.setInputValue('state ='); + + expect(getOptionsContent()).toEqual(['Stopped', 'Stopping', 'Running']); + expect(wrapper.findStatusIndicator()!.getElement()).toHaveTextContent('Loading status'); + }); + + test('shows enum token values and an error indicator if status type is error', () => { + const { wrapper, getOptionsContent } = renderComponent({ filteringStatusType: 'error' }); + + wrapper.focus(); + wrapper.setInputValue('state ='); + + expect(getOptionsContent()).toEqual(['Stopped', 'Stopping', 'Running']); + expect(wrapper.findStatusIndicator()!.getElement()).toHaveTextContent('Error status'); + expect(wrapper.findStatusIndicator()!.getElement()).toHaveTextContent('Retry'); + }); + + test('shows enum token values and finished text if status type is finished', () => { + const { wrapper, getOptionsContent } = renderComponent({ filteringStatusType: 'finished' }); + + wrapper.focus(); + wrapper.setInputValue('state ='); + + expect(getOptionsContent()).toEqual(['Stopped', 'Stopping', 'Running']); + expect(wrapper.findStatusIndicator()!.getElement()).toHaveTextContent('Finished status'); + }); + + test('shows filtering empty if status type is finished and no options', () => { + const { wrapper, getOptionsContent } = renderComponent({ filteringOptions: [], filteringStatusType: 'finished' }); + + wrapper.focus(); + wrapper.setInputValue('state ='); + + expect(getOptionsContent()).toEqual([]); + expect(wrapper.findStatusIndicator()!.getElement().textContent).toBe('Empty'); + }); + + test('calls onLoadItems upon rendering when status type is pending', () => { + const { wrapper, getOptionsContent, onLoadItems } = renderComponent({ + filteringOptions: [], + filteringStatusType: 'pending', + }); + + wrapper.focus(); + wrapper.setInputValue('state = S'); + + expect(getOptionsContent()).toEqual([]); + expect(onLoadItems).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + filteringProperty: expect.objectContaining({ key: 'state' }), + filteringOperator: '=', + filteringText: 'S', + firstPage: true, + samePage: false, + }, + }) + ); + }); + + test('calls onLoadItems upon retrying', () => { + const { wrapper, onLoadItems } = renderComponent({ filteringStatusType: 'error' }); + + wrapper.focus(); + wrapper.setInputValue('state = S'); + + expect(onLoadItems).not.toHaveBeenCalled(); + + wrapper.findErrorRecoveryButton()!.click(); + expect(onLoadItems).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + filteringProperty: expect.objectContaining({ key: 'state' }), + filteringOperator: '=', + filteringText: 'S', + firstPage: false, + samePage: true, + }, + }) + ); + }); +}); + +describe('async token editing', () => { + test('shows enum token values and a loading indicator if status type is loading', () => { + const { openEditor, findEditorMultiselect, getEditorOptionsContent } = renderComponent({ + filteringStatusType: 'loading', + query: { operation: 'and', tokens: [{ propertyKey: 'state', operator: '=', value: ['0'] }] }, + }); + + openEditor(); + findEditorMultiselect().openDropdown(); + + expect(getEditorOptionsContent()).toEqual(['Stopped', 'Stopping', 'Running']); + expect(findEditorMultiselect().findStatusIndicator()!.getElement()).toHaveTextContent('Loading status'); + }); + + test('shows enum token values and an error indicator if status type is error', () => { + const { openEditor, findEditorMultiselect, getEditorOptionsContent } = renderComponent({ + filteringStatusType: 'error', + query: { operation: 'and', tokens: [{ propertyKey: 'state', operator: '=', value: ['0'] }] }, + }); + + openEditor(); + findEditorMultiselect().openDropdown(); + + expect(getEditorOptionsContent()).toEqual(['Stopped', 'Stopping', 'Running']); + expect(findEditorMultiselect().findStatusIndicator()!.getElement()).toHaveTextContent('Error'); + expect(findEditorMultiselect().findStatusIndicator()!.getElement()).toHaveTextContent('Retry'); + }); + + test('shows enum token values and finished text if status type is finished', () => { + const { openEditor, findEditorMultiselect, getEditorOptionsContent } = renderComponent({ + filteringStatusType: 'finished', + query: { operation: 'and', tokens: [{ propertyKey: 'state', operator: '=', value: ['0'] }] }, + }); + + openEditor(); + findEditorMultiselect().openDropdown(); + + expect(getEditorOptionsContent()).toEqual(['Stopped', 'Stopping', 'Running']); + expect(findEditorMultiselect().findStatusIndicator()!.getElement()).toHaveTextContent('Finished status'); + }); + + test('shows filtering empty if status type is finished and no options', () => { + const { openEditor, findEditorMultiselect, getEditorOptionsContent } = renderComponent({ + filteringStatusType: 'finished', + filteringOptions: [], + query: { operation: 'and', tokens: [{ propertyKey: 'state', operator: '=', value: ['0'] }] }, + }); + + openEditor(); + findEditorMultiselect().openDropdown(); + + expect(getEditorOptionsContent()).toEqual([]); + expect(findEditorMultiselect().findStatusIndicator()!.getElement().textContent).toBe('Empty'); + }); + + test('calls onLoadItems upon opening dropdown when status is pending', () => { + const { openEditor, findEditorMultiselect, getEditorOptionsContent, onLoadItems } = renderComponent({ + filteringStatusType: 'pending', + query: { operation: 'and', tokens: [{ propertyKey: 'state', operator: '=', value: ['0'] }] }, + }); + + openEditor(); + findEditorMultiselect().openDropdown(); + + expect(getEditorOptionsContent()).toEqual(['Stopped', 'Stopping', 'Running']); + expect(onLoadItems).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + filteringProperty: expect.objectContaining({ key: 'state' }), + filteringOperator: '=', + filteringText: '', + firstPage: true, + samePage: false, + }, + }) + ); + expect(onLoadItems).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + filteringProperty: expect.objectContaining({ key: 'state' }), + filteringOperator: '=', + filteringText: '', + firstPage: false, + samePage: false, + }, + }) + ); + }); + + test('calls onLoadItems upon retrying', () => { + const { openEditor, findEditorMultiselect, getEditorOptionsContent, onLoadItems } = renderComponent({ + filteringStatusType: 'error', + filteringOptions: [], + query: { operation: 'and', tokens: [{ propertyKey: 'state', operator: '=', value: ['0'] }] }, + }); + + openEditor(); + findEditorMultiselect().openDropdown(); + + expect(getEditorOptionsContent()).toEqual([]); + expect(onLoadItems).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + filteringProperty: expect.objectContaining({ key: 'state' }), + filteringOperator: '=', + filteringText: '', + firstPage: true, + samePage: false, + }, + }) + ); + + findEditorMultiselect().findErrorRecoveryButton()!.click(); + + expect(onLoadItems).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + filteringProperty: expect.objectContaining({ key: 'state' }), + filteringOperator: '=', + filteringText: '', + firstPage: true, + samePage: false, + }, + }) + ); + expect(onLoadItems).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + filteringProperty: expect.objectContaining({ key: 'state' }), + filteringOperator: '=', + filteringText: '', + firstPage: false, + samePage: true, + }, + }) + ); + }); +}); 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 529ea586bd..a89c244322 100644 --- a/src/property-filter/__tests__/property-filter-extended-operators.test.tsx +++ b/src/property-filter/__tests__/property-filter-extended-operators.test.tsx @@ -12,8 +12,6 @@ import { FilteringProperty, PropertyFilterProps, Ref } from '../../../lib/compon import createWrapper, { PropertyFilterWrapper } from '../../../lib/components/test-utils/dom'; import { createDefaultProps } from './common'; -import styles from '../../../lib/components/property-filter/styles.selectors.js'; - const defaultProps = createDefaultProps([], []); const renderComponent = (props?: Partial }>) => { @@ -116,7 +114,7 @@ describe('extended operators', () => { act(() => pageWrapper.find('[data-testid="change+"]')!.click()); // Click cancel - act(() => pageWrapper.findButton(`.${styles['property-editor-cancel']}`)!.click()); + act(() => wrapper.findPropertyCancelButton()!.click()); expect(wrapper.findDropdown()!.findOpenDropdown()).toBe(null); expect(onChange).not.toBeCalled(); expect(wrapper.findNativeInput().getElement()).toHaveFocus(); @@ -126,7 +124,7 @@ describe('extended operators', () => { act(() => pageWrapper.find('[data-testid="change-"]')!.click()); // Click submit - act(() => pageWrapper.findButton(`.${styles['property-editor-submit']}`)!.click()); + act(() => wrapper.findPropertySubmitButton()!.click()); expect(wrapper.findDropdown()!.findOpenDropdown()).toBe(null); expect(onChange).toBeCalledWith( expect.objectContaining({ diff --git a/src/property-filter/__tests__/property-filter.filtering-input.test.tsx b/src/property-filter/__tests__/property-filter.filtering-input.test.tsx index 98641b3f51..2dd4f1a468 100644 --- a/src/property-filter/__tests__/property-filter.filtering-input.test.tsx +++ b/src/property-filter/__tests__/property-filter.filtering-input.test.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { act, render } from '@testing-library/react'; -import { KeyCode } from '@cloudscape-design/test-utils-core/dist/utils'; +import { KeyCode } from '@cloudscape-design/test-utils-core/utils'; import PropertyFilter from '../../../lib/components/property-filter'; import { diff --git a/src/property-filter/__tests__/property-filter.test.tsx b/src/property-filter/__tests__/property-filter.test.tsx index 11183f6a5f..6fd8b887ad 100644 --- a/src/property-filter/__tests__/property-filter.test.tsx +++ b/src/property-filter/__tests__/property-filter.test.tsx @@ -12,7 +12,6 @@ import { FilteringOption, FilteringProperty, PropertyFilterProps, - Ref, } from '../../../lib/components/property-filter/interfaces'; import createWrapper, { ElementWrapper, PropertyFilterWrapper } from '../../../lib/components/test-utils/dom'; import { createDefaultProps } from './common'; @@ -88,7 +87,7 @@ const filteringOptions: readonly FilteringOption[] = [ const defaultProps = createDefaultProps(filteringProperties, filteringOptions); -const renderComponent = (props?: Partial }>) => { +const renderComponent = (props?: Partial) => { const { container } = render(); return { container, propertyFilterWrapper: createWrapper(container).findPropertyFilter()! }; }; diff --git a/src/property-filter/i18n-utils.ts b/src/property-filter/i18n-utils.ts index 4a53bce2d6..27db48453f 100644 --- a/src/property-filter/i18n-utils.ts +++ b/src/property-filter/i18n-utils.ts @@ -64,7 +64,10 @@ export function usePropertyFilterI18n(def: I18nStrings = {}): I18nStringsInterna ) ?? (token => `${token.propertyLabel} ${token.operator} ${token.value}`); function toFormatted(token: InternalToken): FormattedToken { - const valueFormatter = token.property?.getValueFormatter(token.operator); + let valueFormatter = token.property?.getValueFormatter(token.operator); + if (!valueFormatter && token.property?.getTokenType(token.operator) === 'enum') { + valueFormatter = value => (Array.isArray(value) ? value.join(', ') : value); + } const propertyLabel = token.property ? token.property.propertyLabel : allPropertiesLabel ?? ''; const tokenValue = valueFormatter ? valueFormatter(token.value) : token.value; return { propertyKey: token.property?.propertyKey, propertyLabel, operator: token.operator, value: tokenValue }; diff --git a/src/property-filter/interfaces.ts b/src/property-filter/interfaces.ts index fec612f982..12795db2e7 100644 --- a/src/property-filter/interfaces.ts +++ b/src/property-filter/interfaces.ts @@ -16,6 +16,7 @@ import { PropertyFilterQuery, PropertyFilterToken, PropertyFilterTokenGroup, + PropertyFilterTokenType, } from '@cloudscape-design/collection-hooks'; import { AutosuggestProps } from '../autosuggest/interfaces'; @@ -346,6 +347,7 @@ export type ExtendedOperatorForm = PropertyFilterOperatorForm = PropertyFilterOperatorFormat; export type FilteringOption = PropertyFilterProps.FilteringOption; export type FilteringProperty = PropertyFilterProps.FilteringProperty; +export type FilteringPropertyTokenType = PropertyFilterTokenType; export type Query = PropertyFilterProps.Query; export type LoadItemsDetail = PropertyFilterProps.LoadItemsDetail; export type I18nStrings = PropertyFilterProps.I18nStrings; @@ -364,6 +366,7 @@ export interface InternalFilteringProperty { propertyGroup?: string; operators: readonly PropertyFilterOperator[]; defaultOperator: PropertyFilterOperator; + getTokenType: (operator?: PropertyFilterOperator) => FilteringPropertyTokenType; getValueFormatter: (operator?: PropertyFilterOperator) => null | ((value: any) => string); getValueFormRenderer: (operator?: PropertyFilterOperator) => null | PropertyFilterOperatorForm; // Original property used in callbacks. diff --git a/src/property-filter/internal.tsx b/src/property-filter/internal.tsx index 51456c40b7..4fbd1f6c0c 100644 --- a/src/property-filter/internal.tsx +++ b/src/property-filter/internal.tsx @@ -38,7 +38,7 @@ import { Token, TokenGroup, } from './interfaces'; -import { PropertyEditorContent, PropertyEditorFooter } from './property-editor'; +import { PropertyEditorContentCustom, PropertyEditorContentEnum, PropertyEditorFooter } from './property-editor'; import PropertyFilterAutosuggest, { PropertyFilterAutosuggestProps } from './property-filter-autosuggest'; import { TokenButton } from './token'; import { useLoadItems } from './use-load-items'; @@ -128,6 +128,7 @@ const PropertyFilterInternal = React.forwardRef( propertyGroup: property?.group, operators: (property?.operators ?? []).map(op => (typeof op === 'string' ? op : op.operator)), defaultOperator: property?.defaultOperator ?? '=', + getTokenType: operator => (operator ? extendedOperators.get(operator)?.tokenType ?? 'value' : 'value'), getValueFormatter: operator => (operator ? extendedOperators.get(operator)?.format ?? null : null), getValueFormRenderer: operator => (operator ? extendedOperators.get(operator)?.form ?? null : null), externalProperty: property, @@ -303,6 +304,7 @@ const PropertyFilterInternal = React.forwardRef( const customFormValue = customValueKey in customFormValueRecord ? customFormValueRecord[customValueKey] : null; const setCustomFormValue = (value: null | any) => setCustomFormValueRecord({ [customValueKey]: value }); const operatorForm = propertyStep && propertyStep.property.getValueFormRenderer(propertyStep.operator); + const isEnumValue = propertyStep?.property.getTokenType(propertyStep.operator) === 'enum'; const searchResultsId = useUniqueId('property-filter-search-results'); const constraintTextId = useUniqueId('property-filter-constraint'); @@ -334,10 +336,10 @@ const PropertyFilterInternal = React.forwardRef( expandToViewport={expandToViewport} onOptionClick={handleSelected} customForm={ - operatorForm + operatorForm || isEnumValue ? { - content: ( - + ) : ( + ), footer: ( ({ +export function PropertyEditorContentCustom({ property, operator, filter, @@ -32,6 +45,7 @@ export function PropertyEditorContent({
{property.groupValuesLabel}
+
{operatorForm({ value, onChange, operator, filter })} @@ -41,6 +55,59 @@ export function PropertyEditorContent({ ); } +export function PropertyEditorContentEnum({ + property, + filter, + value: unknownValue, + onChange, + asyncProps, + filteringOptions, + onLoadItems, +}: { + property: InternalFilteringProperty; + filter: string; + value: null | string[]; + onChange: (value: null | string[]) => void; + asyncProps: DropdownStatusProps; + filteringOptions: readonly InternalFilteringOption[]; + onLoadItems?: NonCancelableEventHandler; +}) { + const labelId = useUniqueId(); + + const valueOptions = property + ? filteringOptions + .filter(option => option.property?.propertyKey === property.propertyKey) + .map(({ label, value }) => ({ label, value })) + : []; + + const valueHandlers = useLoadItems(onLoadItems, '', property?.externalProperty); + const asyncValueOptionListProps = property?.propertyKey + ? { statusType: 'finished' as const, ...valueHandlers, ...asyncProps, noMatch: asyncProps.empty } + : { statusType: 'finished' as const, empty: asyncProps.empty, noMatch: asyncProps.empty }; + + const value = !unknownValue ? [] : Array.isArray(unknownValue) ? unknownValue : [unknownValue]; + const selectedOptions = valueOptions.filter(option => value.includes(option.value)); + + return ( +
+
+ {property.groupValuesLabel} +
+ + + onChange(e.detail.selectedOptions.map(o => o.value!))} + options={valueOptions} + filteringText={filter} + {...asyncValueOptionListProps} + /> + +
+ ); +} + export function PropertyEditorFooter({ property, operator, @@ -59,10 +126,14 @@ export function PropertyEditorFooter({ const submitToken = () => onSubmit({ property, operator, value }); return (
- + {i18nStrings.cancelActionText} - + {i18nStrings.applyActionText}
diff --git a/src/property-filter/styles.scss b/src/property-filter/styles.scss index 8f8d47f421..8ee5b7d98d 100644 --- a/src/property-filter/styles.scss +++ b/src/property-filter/styles.scss @@ -61,10 +61,6 @@ $operator-field-width: 120px; margin-inline-end: awsui.$space-xs; } - &-submit { - /* used in test-utils */ - } - &-actions { display: flex; justify-content: flex-end; @@ -75,6 +71,12 @@ $operator-field-width: 120px; } } +.property-editor-enum { + display: flex; + flex-direction: column; + overflow-y: hidden; +} + .token-editor { display: flex; flex-direction: column; diff --git a/src/property-filter/test-classes/styles.scss b/src/property-filter/test-classes/styles.scss index e0dc743630..7937362d57 100644 --- a/src/property-filter/test-classes/styles.scss +++ b/src/property-filter/test-classes/styles.scss @@ -66,3 +66,11 @@ .token-editor-submit { /* used in test-utils */ } + +.property-editor-cancel { + /* used in test-utils */ +} + +.property-editor-submit { + /* used in test-utils */ +} diff --git a/src/property-filter/token-editor-inputs.tsx b/src/property-filter/token-editor-inputs.tsx index 257d1940fd..ee556adafe 100644 --- a/src/property-filter/token-editor-inputs.tsx +++ b/src/property-filter/token-editor-inputs.tsx @@ -6,6 +6,7 @@ import React from 'react'; import InternalAutosuggest from '../autosuggest/internal.js'; import { DropdownStatusProps } from '../internal/components/dropdown-status/interfaces.js'; import { NonCancelableEventHandler } from '../internal/events/index.js'; +import InternalMultiselect from '../multiselect/internal.js'; import { SelectProps } from '../select/interfaces.js'; import InternalSelect from '../select/internal.js'; import { getAllowedOperators, getPropertySuggestions } from './controller.js'; @@ -122,14 +123,27 @@ interface ValueInputProps { asyncProps: DropdownStatusProps; filteringOptions: readonly InternalFilteringOption[]; i18nStrings: I18nStringsInternal; - onChangeValue: (value: string) => void; + onChangeValue: (value: unknown) => void; onLoadItems?: NonCancelableEventHandler; operator: undefined | ComparisonOperator; property: null | InternalFilteringProperty; - value: undefined | string; + value: unknown; } -export function ValueInput({ +export function ValueInput(props: ValueInputProps) { + const { property, operator, value, onChangeValue } = props; + const OperatorForm = property?.propertyKey && operator && property?.getValueFormRenderer(operator); + + if (OperatorForm) { + return ; + } + if (property?.getTokenType(operator) === 'enum') { + return ; + } + return ; +} + +function ValueInputAuto({ property, operator, value, @@ -151,11 +165,7 @@ export function ValueInput({ : { empty: asyncProps.empty }; const [matchedOption] = valueOptions.filter(option => option.value === value); - const OperatorForm = property?.propertyKey && operator && property?.getValueFormRenderer(operator); - - return OperatorForm ? ( - - ) : ( + return ( ); } + +function ValueInputEnum({ + property, + operator, + value: unknownValue, + onChangeValue, + asyncProps, + filteringOptions, + onLoadItems, +}: ValueInputProps) { + const valueOptions = property + ? filteringOptions + .filter(option => option.property?.propertyKey === property.propertyKey) + .map(({ label, value }) => ({ label, value })) + : []; + + const valueAutosuggestHandlers = useLoadItems(onLoadItems, '', property?.externalProperty, undefined, operator); + const asyncValueAutosuggestProps = property?.propertyKey + ? { statusType: 'finished' as const, ...valueAutosuggestHandlers, ...asyncProps } + : { statusType: 'finished' as const, empty: asyncProps.empty }; + + const value = Array.isArray(unknownValue) ? unknownValue : [unknownValue]; + const selectedOptions = valueOptions.filter(option => value.includes(option.value)); + + return ( + onChangeValue(e.detail.selectedOptions.map(o => o.value))} + disabled={!operator} + options={valueOptions} + {...asyncValueAutosuggestProps} + inlineTokens={true} + hideTokens={true} + keepOpen={true} + /> + ); +} diff --git a/src/property-filter/token-editor.tsx b/src/property-filter/token-editor.tsx index 47d12c6467..08afbb8a71 100644 --- a/src/property-filter/token-editor.tsx +++ b/src/property-filter/token-editor.tsx @@ -107,7 +107,11 @@ export function TokenEditor({ const operator = temporaryToken.operator; const onChangeOperator = (newOperator: ComparisonOperator) => { - setTemporaryToken({ ...temporaryToken, operator: newOperator }); + const currentOperatorTokenType = property?.getTokenType(operator); + const newOperatorTokenType = property?.getTokenType(newOperator); + const shouldClearValue = currentOperatorTokenType !== newOperatorTokenType; + const value = shouldClearValue ? null : temporaryToken.value; + setTemporaryToken({ ...temporaryToken, operator: newOperator, value }); }; const value = temporaryToken.value; diff --git a/src/property-filter/utils.ts b/src/property-filter/utils.ts index 8587d32c0c..35259054ab 100644 --- a/src/property-filter/utils.ts +++ b/src/property-filter/utils.ts @@ -71,19 +71,27 @@ export function matchTokenValue( { property, operator, value }: InternalToken, filteringOptions: readonly InternalFilteringOption[] ): Token { + const tokenType = property?.getTokenType(operator); const propertyOptions = filteringOptions.filter(option => option.property === property); - const bestMatch: Token = { propertyKey: property?.propertyKey, operator, value }; + const castValue = (value: unknown) => { + if (value === null) { + return tokenType === 'enum' ? [] : null; + } + return tokenType === 'enum' && !Array.isArray(value) ? [value] : value; + }; + const bestMatch: Token = { propertyKey: property?.propertyKey, operator, value: castValue(value) }; + for (const option of propertyOptions) { if ((option.label && option.label === value) || (!option.label && option.value === value)) { // exact match found: return it - return { propertyKey: property?.propertyKey, operator, value: option.value }; + return { propertyKey: property?.propertyKey, operator, value: castValue(option.value) }; } // By default, the token value is a string, but when a custom property is used, // the token value can be any, therefore we need to check for its type before calling toLowerCase() if (typeof value === 'string' && value.toLowerCase() === (option.label ?? option.value ?? '').toLowerCase()) { // non-exact match: save and keep running in case exact match found later - bestMatch.value = option.value; + bestMatch.value = castValue(option.value); } } diff --git a/src/test-utils/dom/property-filter/index.ts b/src/test-utils/dom/property-filter/index.ts index 27a65c1924..3a8601a772 100644 --- a/src/test-utils/dom/property-filter/index.ts +++ b/src/test-utils/dom/property-filter/index.ts @@ -61,6 +61,14 @@ export default class PropertyFilterWrapper extends AutosuggestWrapper { findConstraint(): ElementWrapper | null { return this.findByClassName(styles.constraint); } + + findPropertyCancelButton(): null | ButtonWrapper { + return this.findComponent(`.${testUtilStyles['property-editor-cancel']}`, ButtonWrapper)!; + } + + findPropertySubmitButton(): null | ButtonWrapper { + return this.findComponent(`.${testUtilStyles['property-editor-submit']}`, ButtonWrapper)!; + } } export class FilteringTokenWrapper extends ComponentWrapper {