diff --git a/pages/property-filter/common-props.tsx b/pages/property-filter/common-props.tsx index 907c203129..a2c895b8ba 100644 --- a/pages/property-filter/common-props.tsx +++ b/pages/property-filter/common-props.tsx @@ -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') => @@ -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), }, @@ -166,6 +158,7 @@ export const labels = { filteringErrorText: 'Error fetching results.', filteringRecoveryText: 'Retry', filteringFinishedText: 'End of results', + filteringEmpty: 'No suggestions found', }; export const i18nStrings: PropertyFilterProps.I18nStrings = { @@ -259,7 +252,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') { @@ -302,19 +308,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, @@ -323,3 +316,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..8596f4ca37 100644 --- a/pages/property-filter/property-filter-editor-permutations.page.tsx +++ b/pages/property-filter/property-filter-editor-permutations.page.tsx @@ -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, @@ -43,6 +56,7 @@ const dateProperty: InternalFilteringProperty = { groupValuesLabel: 'Date values', operators: ['=', '!='], defaultOperator: '=', + getTokenType: () => 'value', getValueFormatter: () => (value: Date) => (value ? format(value, 'yyyy-MM-dd') : ''), getValueFormRenderer: () => @@ -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: () => @@ -86,7 +101,11 @@ const defaultProps: Omit = { 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: [], @@ -96,7 +115,7 @@ const defaultProps: Omit = { onChangeTempGroup: () => {}, }; -const tokenPermutations = createPermutations>([ +const editorPermutations = createPermutations>([ // Single name property { tempGroup: [[{ property: nameProperty, operator: '=', value: 'John' }]], @@ -116,7 +135,7 @@ const tokenPermutations = createPermutations>([ [ { 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: [ @@ -160,7 +179,7 @@ export default function () {

Property filter editor permutations

} /> 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 e8dcfa7d2e..3dc3495236 100644 --- a/pages/property-filter/split-panel-app-layout-integration.page.tsx +++ b/pages/property-filter/split-panel-app-layout-integration.page.tsx @@ -218,7 +218,6 @@ export default function () { virtualScroll={virtualScroll} expandToViewport={expandToViewport} countText={`${items.length} matches`} - filteringEmpty="No suggestions found" /> } columnDefinitions={columnDefinitions} diff --git a/pages/property-filter/token-editor.page.tsx b/pages/property-filter/token-editor.page.tsx index 302adc94b3..be475f760c 100644 --- a/pages/property-filter/token-editor.page.tsx +++ b/pages/property-filter/token-editor.page.tsx @@ -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, diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index c79f2d71e5..023eeb56e0 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -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. * propertyKey [string]: The key of the corresponding property in filteringProperties. * operator ['<' | '<=' | '>' | '>=' | ':' | '!:' | '=' | '!=' | '^' | '!^']: The operator which indicates how to filter the dataset using this token. ", diff --git a/src/multiselect/__tests__/multiselect-embedded.test.tsx b/src/multiselect/__tests__/multiselect-embedded.test.tsx index ee3087297b..1243976bff 100644 --- a/src/multiselect/__tests__/multiselect-embedded.test.tsx +++ b/src/multiselect/__tests__/multiselect-embedded.test.tsx @@ -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' }, @@ -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'); diff --git a/src/multiselect/embedded.tsx b/src/multiselect/embedded.tsx index 9a912e381c..e827c24d0b 100644 --- a/src/multiselect/embedded.tsx +++ b/src/multiselect/embedded.tsx @@ -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, @@ -77,12 +74,7 @@ const EmbeddedMultiselect = React.forwardRef( const status = multiselectProps.dropdownStatus; return ( -
+
& DropdownStatusProps & { - controlId: string; + controlId?: string; ariaLabelId: string; footerId: string; filteringValue: string; 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..c7ee700d5d --- /dev/null +++ b/src/property-filter/__tests__/property-filter-enum-tokens.test.tsx @@ -0,0 +1,593 @@ +// 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 = value as string) => { + if (!value) { + return ''; + } + return typeof value === 'string' ? states[value] || fallback : fallback; +}; +const formatStateEnum = (value: string[]) => { + if (value === null) { + return '{null}'; + } + return value.map(entry => formatState(entry, 'Unknown option')).join(', '); +}; + +const filteringProperties: readonly FilteringProperty[] = [ + { + key: 'state', + propertyLabel: 'State', + operators: [ + { operator: '=', format: formatStateEnum, tokenType: 'enum' }, + { operator: '!=', format: formatStateEnum, tokenType: 'enum' }, + { operator: ':', format: formatState }, + { operator: '!:', format: formatState }, + ], + 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() ?? wrapper.findTokens()[0].findEditButton()).click(); + const findEditor = () => wrapper.findTokens()[0].findEditorDropdown()!; + const getTokensContent = () => wrapper.findTokens().map(w => w.findLabel().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, + }; +}; + +test('formats tokens using custom and default formatters', () => { + const { getTokensContent } = renderComponent({ + query: { + operation: 'and', + tokens: [ + { propertyKey: 'state', operator: ':', value: '0' }, + { propertyKey: 'state', operator: ':', value: 'ing' }, + { propertyKey: 'state', operator: '=', value: ['0', '1'] }, + { propertyKey: 'tags', operator: '=', value: ['A', 'B'] }, + ], + }, + }); + expect(getTokensContent()).toEqual(['State : Stopped', 'State : ing', 'State = Stopped, Stopping', 'Tags = A, B']); +}); + +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 = Unknown option']); + 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('can change value in enum token', () => { + const { openEditor, findEditor, findEditorMultiselect, getTokensContent, onChange } = renderComponent({ + query: { operation: 'and', tokens: [{ propertyKey: 'state', operator: '=', value: ['0'] }] }, + }); + + openEditor(); + findEditorMultiselect().openDropdown(); + findEditorMultiselect().selectOptionByValue('1'); + findEditor().findSubmitButton().click(); + + expect(getTokensContent()).toEqual(['State = Stopped, Stopping']); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { operation: 'and', tokens: [{ propertyKey: 'state', operator: '=', value: ['0', '1'] }] }, + }) + ); + }); + + 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']); + }); + + test('can render enum query tokens with value=null', () => { + const { openEditor, findEditor, onChange } = renderComponent({ + enableTokenGroups: true, + query: { + operation: 'and', + tokens: [], + tokenGroups: [ + { + operation: 'or', + tokens: [ + { propertyKey: 'state', operator: '=', value: null }, + { propertyKey: 'tags', operator: '=', value: null }, + ], + }, + ], + }, + }); + + openEditor(); + findEditor().findSubmitButton().click(); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + operation: 'and', + tokens: [], + tokenGroups: [ + { + operation: 'or', + tokens: [ + { propertyKey: 'state', operator: '=', value: [] }, + { propertyKey: 'tags', operator: '=', value: [] }, + ], + }, + ], + }, + }) + ); + }); +}); + +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.test.tsx b/src/property-filter/__tests__/property-filter.test.tsx index 3826474aff..65cf3bbd58 100644 --- a/src/property-filter/__tests__/property-filter.test.tsx +++ b/src/property-filter/__tests__/property-filter.test.tsx @@ -113,8 +113,6 @@ describe('property filter parts', () => { expandToViewport: true, query: { tokens: [{ propertyKey: 'string', value: 'first', operator: ':' }], operation: 'or' }, filteringStatusType: 'error', - filteringRecoveryText: 'recovery', - filteringErrorText: 'error', }); // find dropdown returns open dropdown expect(wrapper.findDropdown({ expandToViewport: true })).toBeNull(); @@ -123,8 +121,8 @@ describe('property filter parts', () => { expect(wrapper.findDropdown({ expandToViewport: true }).findOpenDropdown()!.getElement()).not.toBeNull(); wrapper.setInputValue('string'); expect(wrapper.findEnteredTextOption({ expandToViewport: true })!.getElement()).toHaveTextContent('Use: "string"'); - expect(wrapper.findErrorRecoveryButton({ expandToViewport: true })!.getElement()).toHaveTextContent('recovery'); - expect(wrapper.findStatusIndicator({ expandToViewport: true })!.getElement()).toHaveTextContent('error'); + expect(wrapper.findErrorRecoveryButton({ expandToViewport: true })!.getElement()).toHaveTextContent('Retry'); + expect(wrapper.findStatusIndicator({ expandToViewport: true })!.getElement()).toHaveTextContent('Error status'); wrapper.selectSuggestion(2, { expandToViewport: true }); expect(wrapper.findNativeInput().getElement()).toHaveValue('string != '); }); @@ -133,38 +131,32 @@ describe('property filter parts', () => { test('displays error status', () => { const { propertyFilterWrapper: wrapper } = renderComponent({ filteringStatusType: 'error', - filteringErrorText: 'Error text', }); wrapper.findNativeInput().focus(); wrapper.setInputValue('string'); - expect(wrapper.findStatusIndicator()!.getElement()).toHaveTextContent('Error text'); + expect(wrapper.findStatusIndicator()!.getElement()).toHaveTextContent('Error status'); }); test('links error status to dropdown', () => { - const { propertyFilterWrapper: wrapper } = renderComponent({ - filteringStatusType: 'error', - filteringErrorText: 'Error text', - }); + const { propertyFilterWrapper: wrapper } = renderComponent({ filteringStatusType: 'error' }); wrapper.findNativeInput().focus(); wrapper.setInputValue('string'); - expect(wrapper.findDropdown().find('ul')!.getElement()).toHaveAccessibleDescription(`Error text`); + expect(wrapper.findDropdown().find('ul')!.getElement()).toHaveAccessibleDescription(`Error status Retry`); }); test('displays finished status', () => { const { propertyFilterWrapper: wrapper } = renderComponent({ filteringStatusType: 'finished', - filteringFinishedText: 'Finished text', }); wrapper.findNativeInput().focus(); wrapper.setInputValue('string'); - expect(wrapper.findStatusIndicator()!.getElement()).toHaveTextContent('Finished text'); + expect(wrapper.findStatusIndicator()!.getElement()).toHaveTextContent('Finished status'); }); test('links finished status to dropdown', () => { const { propertyFilterWrapper: wrapper } = renderComponent({ filteringStatusType: 'finished', - filteringFinishedText: 'Finished text', }); wrapper.findNativeInput().focus(); wrapper.setInputValue('string'); - expect(wrapper.findDropdown().find('ul')!.getElement()).toHaveAccessibleDescription(`Finished text`); + expect(wrapper.findDropdown().find('ul')!.getElement()).toHaveAccessibleDescription(`Finished status`); }); }); 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/index.tsx b/src/property-filter/index.tsx index cddc63440c..830d430550 100644 --- a/src/property-filter/index.tsx +++ b/src/property-filter/index.tsx @@ -18,6 +18,7 @@ export { PropertyFilterProps }; const PropertyFilter = React.forwardRef( ( { + filteringProperties, filteringOptions = [], customGroupsText = [], enableTokenGroups = false, @@ -31,6 +32,19 @@ const PropertyFilter = React.forwardRef( }: PropertyFilterProps, ref: React.Ref ) => { + let hasCustomForms = false; + let hasEnumTokens = false; + let hasCustomFormatters = false; + for (const property of filteringProperties) { + for (const operator of property.operators ?? []) { + if (typeof operator === 'object') { + hasCustomForms = hasCustomForms || !!operator.form; + hasEnumTokens = hasEnumTokens || operator.tokenType === 'enum'; + hasCustomFormatters = hasCustomFormatters || !!operator.format; + } + } + } + const baseComponentProps = useBaseComponent('PropertyFilter', { props: { asyncProperties, @@ -41,6 +55,11 @@ const PropertyFilter = React.forwardRef( tokenLimit, virtualScroll, }, + metadata: { + hasCustomForms, + hasEnumTokens, + hasCustomFormatters, + }, }); const componentAnalyticsMetadata: GeneratedAnalyticsMetadataPropertyFilterComponent = { @@ -61,6 +80,7 @@ const PropertyFilter = React.forwardRef( ' | '>=' | ':' | '!:' | '=' | '!=' | '^' | '!^']: The operator which indicates how to filter the dataset using this token. */ @@ -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 bea9f98ee7..29480e1beb 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'); @@ -335,10 +337,10 @@ const PropertyFilterInternal = React.forwardRef( expandToViewport={expandToViewport} onOptionClick={handleSelected} customForm={ - operatorForm + operatorForm || isEnumValue ? { - content: ( - + ) : ( + ), footer: ( ({ +export function PropertyEditorContentCustom({ property, operator, filter, @@ -34,6 +47,7 @@ export function PropertyEditorContent({
{property.groupValuesLabel}
+
{operatorForm({ value, onChange, operator, filter })} @@ -43,6 +57,55 @@ export function PropertyEditorContent({ ); } +export function PropertyEditorContentEnum({ + property, + filter, + value: unknownValue, + onChange, + asyncProps, + filteringOptions, + onLoadItems, +}: { + property: InternalFilteringProperty; + filter: string; + value: unknown; + onChange: (value: null | string[]) => void; + asyncProps: DropdownStatusProps; + filteringOptions: readonly InternalFilteringOption[]; + onLoadItems?: NonCancelableEventHandler; +}) { + const valueOptions: readonly { value: string; label: string }[] = filteringOptions + .filter(option => option.property?.propertyKey === property.propertyKey) + .map(({ label, value }) => ({ label, value })); + const valueHandlers = useLoadItems(onLoadItems, '', property.externalProperty); + const value = !unknownValue ? [] : Array.isArray(unknownValue) ? unknownValue : [unknownValue]; + const selectedOptions = valueOptions.filter(option => value.includes(option.value)); + const filteredOptions = filterOptions(valueOptions, filter); + return ( +
+ {filteredOptions.length === 0 && ( +
+ + {property.groupValuesLabel} +
+ )} + + onChange(e.detail.selectedOptions.map(o => o.value!))} + options={filteredOptions.length > 0 ? [{ options: filteredOptions, label: property.groupValuesLabel }] : []} + filteringText={filter} + ariaLabel={property.groupValuesLabel} + statusType="finished" + noMatch={asyncProps.empty} + {...valueHandlers} + {...asyncProps} + /> +
+ ); +} + export function PropertyEditorFooter({ property, operator, diff --git a/src/property-filter/styles.scss b/src/property-filter/styles.scss index f553a8f428..7208566cd8 100644 --- a/src/property-filter/styles.scss +++ b/src/property-filter/styles.scss @@ -55,6 +55,18 @@ $operator-field-width: 120px; padding-inline: awsui.$space-s; } + &-header-enum { + display: flex; + gap: awsui.$space-xs; + + @include styles.default-text-style; + font-weight: bold; + color: awsui.$color-text-dropdown-group-label; + + padding-block: styles.$group-option-padding-with-border-placeholder-vertical; + padding-inline: calc(#{styles.$control-padding-horizontal} + #{awsui.$border-item-width}); + } + &-form { padding-block-start: awsui.$space-xxs; padding-block-end: awsui.$space-s; @@ -75,6 +87,11 @@ $operator-field-width: 120px; } } +.property-editor-enum { + display: flex; + flex-direction: column; +} + .token-editor { display: flex; flex-direction: column; @@ -98,6 +115,17 @@ $operator-field-width: 120px; flex-grow: 2; } + &-multiselect-wrapper { + position: relative; + block-size: awsui.$size-vertical-input; + min-inline-size: 200px; + + &-inner { + position: absolute; + inline-size: 100%; + } + } + &-cancel { margin-inline-end: awsui.$space-xs; } diff --git a/src/property-filter/token-editor-inputs.tsx b/src/property-filter/token-editor-inputs.tsx index 815586721d..00b9b3ce78 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'; @@ -20,6 +21,8 @@ import { } from './interfaces.js'; import { useLoadItems } from './use-load-items.js'; +import styles from './styles.css.js'; + interface PropertyInputProps { asyncProps: null | DropdownStatusProps; customGroupsText: readonly GroupText[]; @@ -122,14 +125,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 && operator && property.getTokenType(operator) === 'enum') { + return ; + } + return ; +} + +function ValueInputAuto({ property, operator, value, @@ -145,17 +161,14 @@ export function ValueInput({ .map(({ label, value }) => ({ label, value })) : []; - const valueAutosuggestHandlers = useLoadItems(onLoadItems, '', property?.externalProperty, value, operator); + const valueFilter = typeof value === 'string' ? value : ''; + const valueAutosuggestHandlers = useLoadItems(onLoadItems, '', property?.externalProperty, valueFilter, operator); const asyncValueAutosuggestProps = property?.propertyKey ? { ...valueAutosuggestHandlers, ...asyncProps } : { empty: asyncProps.empty }; const [matchedOption] = valueOptions.filter(option => option.value === value); - const OperatorForm = property?.propertyKey && operator && property?.getValueFormRenderer(operator); - - return OperatorForm ? ( - - ) : ( + return ( ); } + +interface ValueInputPropsEnum extends ValueInputProps { + property: InternalFilteringProperty; + operator: ComparisonOperator; +} + +function ValueInputEnum({ + property, + operator, + value: unknownValue, + onChangeValue, + asyncProps, + filteringOptions, + onLoadItems, +}: ValueInputPropsEnum) { + const valueOptions = filteringOptions + .filter(option => option.property?.propertyKey === property.propertyKey) + .map(({ label, value }) => ({ label, value })); + const valueAutosuggestHandlers = useLoadItems(onLoadItems, '', property.externalProperty, undefined, operator); + const asyncValueAutosuggestProps = { statusType: 'finished' as const, ...valueAutosuggestHandlers, ...asyncProps }; + const value = !unknownValue ? [] : Array.isArray(unknownValue) ? unknownValue : [unknownValue]; + const selectedOptions = valueOptions.filter(option => value.includes(option.value)); + return ( +
+
+ onChangeValue(e.detail.selectedOptions.map(o => o.value))} + options={valueOptions.length > 0 ? [{ options: valueOptions, label: property.groupValuesLabel }] : []} + {...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..f32cf259aa 100644 --- a/src/property-filter/token-editor.tsx +++ b/src/property-filter/token-editor.tsx @@ -88,6 +88,9 @@ export function TokenEditor({ const setTemporaryToken = (newToken: InternalToken) => { const copy = [...tempGroup]; copy[index] = newToken; + if (newToken.property?.getTokenType(newToken.operator) === 'enum' && newToken.value === null) { + newToken.value = []; + } onChangeTempGroup(copy); }; const property = temporaryToken.property; @@ -107,7 +110,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); } }