diff --git a/src/property-filter/__tests__/extended-wrapper.ts b/src/property-filter/__tests__/extended-wrapper.ts new file mode 100644 index 0000000000..9ca8a0ef33 --- /dev/null +++ b/src/property-filter/__tests__/extended-wrapper.ts @@ -0,0 +1,278 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import createWrapper, { + AutosuggestWrapper, + FormFieldWrapper, + MultiselectWrapper, + SelectWrapper, +} from '../../../lib/components/test-utils/dom'; + +import itemStyles from '../../../lib/components/internal/components/selectable-item/styles.css.js'; +import selectableStyles from '../../../lib/components/internal/components/selectable-item/styles.selectors.js'; +import propertyFilterStyles from '../../../lib/components/property-filter/styles.selectors.js'; +import selectStyles from '../../../lib/components/select/parts/styles.selectors.js'; + +export function createExtendedWrapper() { + const wrapper = createWrapper().findPropertyFilter()!; + function findEditorDropdown() { + return wrapper + .findTokens() + .map(w => w.findEditorDropdown()) + .filter(Boolean)[0]!; + } + function findControl(fieldIndex: number, fieldType: 'property' | 'operator' | 'value') { + const dropdown = findEditorDropdown(); + const field = { + property: dropdown.findPropertyField(fieldIndex), + operator: dropdown.findOperatorField(fieldIndex), + value: dropdown.findValueField(fieldIndex), + }[fieldType]; + return field.findControl()!; + } + + return { + input: { + focus() { + wrapper.focus(); + }, + value(text: string) { + wrapper.setInputValue(text); + }, + date(text: string) { + wrapper.findDropdown().findOpenDropdown()!.findDateInput()!.setInputValue(text); + }, + keys(...codes: number[]) { + codes.forEach(code => wrapper.findNativeInput().keydown(code)); + }, + submit() { + wrapper.findPropertySubmitButton()!.click(); + }, + cancel() { + wrapper.findPropertyCancelButton()!.click(); + }, + selectByValue(value: null | string) { + wrapper.selectSuggestionByValue(value as any); + }, + selectAll() { + wrapper + .findDropdown() + .findOpenDropdown()! + .findByClassName(selectableStyles['selectable-item'])! + .fireEvent(new MouseEvent('mouseup', { bubbles: true })); + }, + dropdown() { + return !!wrapper.findDropdown().findOpenDropdown(); + }, + status() { + return wrapper.findDropdown().findFooterRegion()?.getElement().textContent ?? ''; + }, + options() { + const enteredTextOption = wrapper.findEnteredTextOption()?.getElement().textContent; + const otherOptions = printOptions(wrapper); + return [enteredTextOption, ...otherOptions].filter(Boolean); + }, + }, + tokens: { + token(index = 1) { + const token = wrapper.findTokens()[index - 1]; + return { + nested(index = 1) { + const nested = token.findGroupTokens()[index - 1]; + return { + operation(value: 'and' | 'or') { + nested.findTokenOperation()!.openDropdown(); + nested.findTokenOperation()?.selectOptionByValue(value); + }, + }; + }, + operation(value: 'and' | 'or') { + token.findTokenOperation()!.openDropdown(); + token.findTokenOperation()?.selectOptionByValue(value); + }, + }; + }, + list() { + return wrapper.findTokens().map(w => { + const operation = w.findTokenOperation()?.find('button')?.getElement().textContent; + const value = w.findLabel()?.getElement().textContent; + const nested = w + .findGroupTokens() + .map(w => { + const operation = w.findTokenOperation()?.find('button')?.getElement().textContent; + const value = w.findLabel()?.getElement().textContent; + return [operation, value].filter(Boolean).join(' '); + }) + .join(' '); + return [operation, value, nested].filter(Boolean).join(' '); + }); + }, + }, + editor: { + dropdown() { + return !!findEditorDropdown(); + }, + open(index: number) { + const token = wrapper.findTokens()[index - 1]; + const target = token.findEditButton() ?? token.findLabel(); + target.click(); + }, + property(fieldIndex = 1) { + const select = findControl(fieldIndex, 'property').findSelect()!; + return { + open() { + dropdownOpen(select); + }, + value(option: null | string) { + dropdownOpen(select); + select.selectOptionByValue(option as any); + }, + options() { + dropdownOpen(select); + return printOptions(select); + }, + }; + }, + operator(fieldIndex = 1) { + const select = findControl(fieldIndex, 'operator').findSelect()!; + return { + open() { + dropdownOpen(select); + }, + value(option: null | string) { + dropdownOpen(select); + select.selectOptionByValue(option as any); + }, + options() { + dropdownOpen(select); + return printOptions(select); + }, + }; + }, + value(fieldIndex = 1) { + return { + autosuggest() { + const autosuggest = findControl(fieldIndex, 'value').findAutosuggest()!; + return { + focus() { + autosuggest.focus(); + }, + option(value: string) { + autosuggest.focus(); + autosuggest.selectSuggestionByValue(value); + }, + input(text: string) { + autosuggest.setInputValue(text); + }, + options() { + return printOptions(autosuggest); + }, + }; + }, + multiselect() { + const multiselect = findControl(fieldIndex, 'value').findMultiselect()!; + return { + open() { + dropdownOpen(multiselect); + }, + filter(text: string) { + dropdownOpen(multiselect); + multiselect.findFilteringInput()!.setInputValue(text); + }, + value(options: (null | string)[]) { + dropdownOpen(multiselect); + options.forEach(option => multiselect.selectOptionByValue(option as any)); + }, + options() { + dropdownOpen(multiselect); + return printOptions(multiselect); + }, + }; + }, + dateInput() { + const dateInput = findControl(fieldIndex, 'value').findDateInput()!; + return { + value(value: string) { + dateInput.setInputValue(value); + }, + }; + }, + }; + }, + remove(rowIndex: number) { + findEditorDropdown().findTokenRemoveActions(rowIndex)!.openDropdown(); + findEditorDropdown().findTokenRemoveActions(rowIndex)!.findItemById('remove')!.click(); + }, + addFilter() { + findEditorDropdown().findTokenAddActions()!.findMainAction()!.click(); + }, + submit() { + findEditorDropdown().findSubmitButton()!.click(); + }, + cancel() { + findEditorDropdown().findCancelButton()!.click(); + }, + form() { + const dropdown = findEditorDropdown(); + const rows: string[] = []; + for (let index = 1; index <= 10; index++) { + const property = printField(dropdown.findPropertyField(index), 'property'); + const operator = printField(dropdown.findOperatorField(index), 'operator'); + const value = printField(dropdown.findValueField(index), 'value'); + if (property || operator || value) { + rows.push([property, operator, value].join(' ')); + } else { + break; + } + } + return rows; + }, + }, + }; +} + +function dropdownOpen(wrapper: SelectWrapper | MultiselectWrapper) { + if (!wrapper.findDropdown().findOpenDropdown()) { + wrapper.openDropdown(); + } +} + +function printOptions(wrapper: AutosuggestWrapper | SelectWrapper | MultiselectWrapper) { + const options = wrapper + .findDropdown() + .findOptions() + .flatMap(w => { + const selectOptions = w.findAllByClassName(itemStyles['option-content']); + const options = selectOptions.length > 0 ? selectOptions : [w]; + return options.map(w => w.getElement().textContent); + }); + return options.filter(Boolean); +} + +function printField(wrapper: null | FormFieldWrapper, type: 'property' | 'operator' | 'value') { + if (!wrapper) { + return null; + } + const headers = createWrapper().findAllByClassName(propertyFilterStyles['token-editor-grid-header']); + const header = headers[['property', 'operator', 'value'].indexOf(type)]; + const formFieldLabel = wrapper.findLabel()?.getElement().textContent ?? header?.getElement().textContent; + const autosuggest = wrapper.findControl()!.findAutosuggest(); + const dateInput = wrapper.findControl()?.findDateInput(); + if (autosuggest || dateInput) { + const input = autosuggest?.findNativeInput() ?? dateInput?.findNativeInput(); + const value = input!.getElement().value; + return `${formFieldLabel}[${value}]`; + } + const select = wrapper.findControl()!.findSelect(); + if (select) { + const value = select.getElement().textContent; + return `${formFieldLabel}[${value}]`; + } + const multiselect = wrapper.findControl()!.findMultiselect(); + if (multiselect) { + const tokens = multiselect.findAllByClassName(selectStyles['inline-token']); + const value = tokens.map(w => w.getElement().textContent).join(', '); + return `${formFieldLabel}[${value}]`; + } + return `${formFieldLabel}[]`; +} diff --git a/src/property-filter/__tests__/property-filter-i18n.test.tsx b/src/property-filter/__tests__/property-filter-i18n.test.tsx index 231f400f5c..81c7313b2e 100644 --- a/src/property-filter/__tests__/property-filter-i18n.test.tsx +++ b/src/property-filter/__tests__/property-filter-i18n.test.tsx @@ -300,7 +300,6 @@ describe('i18n', () => { enableTokenGroups={true} filteringOptions={[]} customGroupsText={[]} - disableFreeTextFiltering={false} /> ); diff --git a/src/property-filter/__tests__/property-filter-stories-token-create-async.test.tsx b/src/property-filter/__tests__/property-filter-stories-token-create-async.test.tsx new file mode 100644 index 0000000000..dc4be4553c --- /dev/null +++ b/src/property-filter/__tests__/property-filter-stories-token-create-async.test.tsx @@ -0,0 +1,109 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { KeyCode } from '@cloudscape-design/test-utils-core/utils'; + +import { PropertyFilterProps } from '../interfaces'; +import { createRenderer } from './stories-components'; + +const defaultProps: Partial = { + filteringProperties: [ + { + key: 'id', + propertyLabel: 'ID', + operators: ['=', '!='], + groupValuesLabel: 'ID values', + }, + { + key: 'status', + propertyLabel: 'Status', + operators: [ + { operator: '=', tokenType: 'enum' }, + { operator: '!=', tokenType: 'enum' }, + ], + groupValuesLabel: 'Status values', + }, + ], + filteringOptions: [ + { propertyKey: 'id', value: 'x-1' }, + { propertyKey: 'id', value: 'x-2' }, + { propertyKey: 'id', value: 'x-3' }, + { propertyKey: 'id', value: 'x-4' }, + { propertyKey: 'status', value: 'Active' }, + { propertyKey: 'status', value: 'Activating' }, + { propertyKey: 'status', value: 'Inactive' }, + ], +}; +const render = createRenderer(defaultProps, { async: true }); + +describe('Property filter stories: tokens creation, async', () => { + test('waits and selects property, then waits and selects option', async () => { + const { wrapper } = render({ asyncProperties: true }); + + wrapper.input.focus(); + expect(wrapper.input.dropdown()).toBe(true); + expect(wrapper.input.options()).toEqual([]); + expect(wrapper.input.status()).toBe('Loading status'); + + await new Promise(resolve => setTimeout(resolve, 1)); + expect(wrapper.input.options()).toEqual(['ID', 'Status']); + expect(wrapper.input.status()).toBe('Finished status'); + + wrapper.input.value('ID'); + expect(wrapper.input.options()).toEqual(['Use: "ID"', 'ID =Equals', 'ID !=Does not equal']); + + wrapper.input.selectByValue('ID = '); + expect(wrapper.input.options()).toEqual(['Use: "ID = "', 'ID = x-1', 'ID = x-2', 'ID = x-3', 'ID = x-4']); + expect(wrapper.input.status()).toBe('Loading status'); + + await new Promise(resolve => setTimeout(resolve, 1)); + expect(wrapper.input.options()).toEqual(['Use: "ID = "', 'ID = x-1', 'ID = x-2', 'ID = x-3', 'ID = x-4']); + expect(wrapper.input.status()).toBe('Finished status'); + + wrapper.input.selectByValue('ID = x-2'); + expect(wrapper.input.dropdown()).toBe(false); + expect(wrapper.tokens.list()).toEqual(['ID = x-2']); + }); + + test('searches token by value, waits and selects the matched option', async () => { + const { wrapper } = render({ asyncProperties: false }); + + wrapper.input.focus(); + expect(wrapper.input.dropdown()).toBe(true); + expect(wrapper.input.options()).toEqual(['ID', 'Status']); + expect(wrapper.input.status()).toBe(''); + + wrapper.input.value('x-3'); + expect(wrapper.input.options()).toEqual(['Use: "x-3"']); + expect(wrapper.input.status()).toBe('Loading status'); + + await new Promise(resolve => setTimeout(resolve, 1)); + expect(wrapper.input.options()).toEqual(['Use: "x-3"', 'ID = x-3']); + expect(wrapper.input.status()).toBe('Finished status'); + + wrapper.input.keys(KeyCode.down, KeyCode.down, KeyCode.enter); + expect(wrapper.tokens.list()).toEqual(['ID = x-3']); + }); + + test('searches enum token value, waits and selects all matched options', async () => { + const { wrapper } = render({ asyncProperties: false }); + + wrapper.input.focus(); + expect(wrapper.input.dropdown()).toBe(true); + expect(wrapper.input.options()).toEqual(['ID', 'Status']); + expect(wrapper.input.status()).toBe(''); + + wrapper.input.value('Status = Activ'); + expect(wrapper.input.options()).toEqual([]); + expect(wrapper.input.status()).toBe('Loading status'); + + await new Promise(resolve => setTimeout(resolve, 1)); + expect(wrapper.input.options()).toEqual(['Active', 'Activating']); + expect(wrapper.input.status()).toBe('Finished status'); + + wrapper.input.selectAll(); + wrapper.input.submit(); + expect(wrapper.input.dropdown()).toBe(false); + expect(wrapper.tokens.list()).toEqual(['Status = Active, Activating']); + }); +}); diff --git a/src/property-filter/__tests__/property-filter-stories-token-create-sync.test.tsx b/src/property-filter/__tests__/property-filter-stories-token-create-sync.test.tsx new file mode 100644 index 0000000000..c5bdf1f1b6 --- /dev/null +++ b/src/property-filter/__tests__/property-filter-stories-token-create-sync.test.tsx @@ -0,0 +1,317 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import { KeyCode } from '@cloudscape-design/test-utils-core/utils'; + +import DateInput from '../../../lib/components/date-input'; +import { PropertyFilterProps } from '../interfaces'; +import { createRenderer } from './stories-components'; + +const defaultProps: Partial = { + filteringProperties: [ + { + key: 'id', + propertyLabel: 'ID', + operators: ['=', '!='], + groupValuesLabel: 'ID values', + }, + { + key: 'name', + propertyLabel: 'Name', + operators: [':', '!:'], + groupValuesLabel: 'Name values', + defaultOperator: ':', + }, + { + key: 'active', + propertyLabel: 'Active', + operators: ['='], + groupValuesLabel: 'Active values', + }, + { + key: 'status', + propertyLabel: 'Status', + operators: [ + { operator: '=', tokenType: 'enum' }, + { operator: '!=', tokenType: 'enum' }, + ], + groupValuesLabel: 'Status values', + }, + { + key: 'creationDate', + propertyLabel: 'Created at', + operators: [ + { + operator: '=', + form: props => props.onChange(detail.value)} />, + format: token => (token ? `[${token}]` : 'not specified'), + }, + ], + groupValuesLabel: 'Creation date values', + }, + ], + filteringOptions: [ + { propertyKey: 'id', value: 'x-1' }, + { propertyKey: 'id', value: 'x-2' }, + { propertyKey: 'id', value: 'x-3' }, + { propertyKey: 'name', value: 'John' }, + { propertyKey: 'active', value: 'Yes' }, + { propertyKey: 'active', value: 'No' }, + { propertyKey: 'status', value: 'Active' }, + { propertyKey: 'status', value: 'Activating' }, + { propertyKey: 'status', value: 'Inactive' }, + ], +}; +const render = createRenderer(defaultProps); + +describe('Property filter stories: tokens creation, sync', () => { + test('creates token with 3 clicks', () => { + const { wrapper } = render({}); + + wrapper.input.focus(); + expect(wrapper.input.dropdown()).toBe(true); + expect(wrapper.input.options()).toEqual(['ID', 'Name', 'Active', 'Status', 'Created at']); + + wrapper.input.selectByValue('ID'); + expect(wrapper.input.options()).toEqual(['Use: "ID"', 'ID =Equals', 'ID !=Does not equal']); + + wrapper.input.selectByValue('ID = '); + expect(wrapper.input.options()).toEqual(['Use: "ID = "', 'ID = x-1', 'ID = x-2', 'ID = x-3']); + + wrapper.input.selectByValue('ID = x-1'); + expect(wrapper.input.dropdown()).toBe(false); + expect(wrapper.tokens.list()).toEqual(['ID = x-1']); + }); + + test('creates token with 2 clicks (for single-operator property)', () => { + const { wrapper } = render({}); + + wrapper.input.focus(); + expect(wrapper.input.dropdown()).toBe(true); + expect(wrapper.input.options()).toEqual(['ID', 'Name', 'Active', 'Status', 'Created at']); + + wrapper.input.selectByValue('Active'); + expect(wrapper.input.options()).toEqual(['Use: "Active = "', 'Active = Yes', 'Active = No']); + + wrapper.input.selectByValue('Active = No'); + expect(wrapper.input.dropdown()).toBe(false); + expect(wrapper.tokens.list()).toEqual(['Active = No']); + }); + + test('creates token with search and keyboard select', () => { + const { wrapper } = render({}); + + wrapper.input.focus(); + expect(wrapper.input.dropdown()).toBe(true); + expect(wrapper.input.options()).toEqual(['ID', 'Name', 'Active', 'Status', 'Created at']); + + wrapper.input.value('Na'); + expect(wrapper.input.options()).toEqual(['Use: "Na"', 'Name', 'Status = Inactive']); + + wrapper.input.keys(KeyCode.down, KeyCode.down, KeyCode.enter); + expect(wrapper.input.options()).toEqual(['Use: "Name"', 'Name :Contains', 'Name !:Does not contain']); + + wrapper.input.value('Name !'); + expect(wrapper.input.options()).toEqual(['Use: "Name !"', 'Name !:Does not contain']); + + wrapper.input.value('Name !: Jo'); + expect(wrapper.input.options()).toEqual(['Use: "Name !: Jo"', 'Name !: John']); + + wrapper.input.keys(KeyCode.down, KeyCode.down, KeyCode.enter); + expect(wrapper.input.dropdown()).toBe(false); + expect(wrapper.tokens.list()).toEqual(['Name !: John']); + }); + + test('creates token with value search', () => { + const { wrapper } = render({}); + + wrapper.input.focus(); + expect(wrapper.input.dropdown()).toBe(true); + expect(wrapper.input.options()).toEqual(['ID', 'Name', 'Active', 'Status', 'Created at']); + + wrapper.input.value('x-2'); + expect(wrapper.input.options()).toEqual(['Use: "x-2"', 'ID = x-2']); + + wrapper.input.keys(KeyCode.down, KeyCode.down, KeyCode.enter); + expect(wrapper.input.dropdown()).toBe(false); + expect(wrapper.tokens.list()).toEqual(['ID = x-2']); + }); + + test('creates free-text token with by pressing enter', () => { + const { wrapper } = render({}); + + wrapper.input.focus(); + expect(wrapper.input.dropdown()).toBe(true); + expect(wrapper.input.options()).toEqual(['ID', 'Name', 'Active', 'Status', 'Created at']); + + wrapper.input.value('x-2'); + expect(wrapper.input.options()).toEqual(['Use: "x-2"', 'ID = x-2']); + + wrapper.input.keys(KeyCode.enter); + expect(wrapper.input.dropdown()).toBe(false); + expect(wrapper.tokens.list()).toEqual(['x-2']); + }); + + test('creates free-text token by selecting "use" option', () => { + const { wrapper } = render({}); + + wrapper.input.focus(); + expect(wrapper.input.dropdown()).toBe(true); + expect(wrapper.input.options()).toEqual(['ID', 'Name', 'Active', 'Status', 'Created at']); + + wrapper.input.value('x-2'); + expect(wrapper.input.options()).toEqual(['Use: "x-2"', 'ID = x-2']); + + wrapper.input.keys(KeyCode.down, KeyCode.enter); + expect(wrapper.input.dropdown()).toBe(false); + expect(wrapper.tokens.list()).toEqual(['x-2']); + }); + + test('creates token with 3 clicks with disallowed free-text selection', () => { + const { wrapper } = render({ disableFreeTextFiltering: true }); + + wrapper.input.focus(); + expect(wrapper.input.dropdown()).toBe(true); + expect(wrapper.input.options()).toEqual(['ID', 'Name', 'Active', 'Status', 'Created at']); + + wrapper.input.selectByValue('ID'); + expect(wrapper.input.options()).toEqual(['ID =Equals', 'ID !=Does not equal']); + + wrapper.input.selectByValue('ID = '); + expect(wrapper.input.options()).toEqual(['Use: "ID = "', 'ID = x-1', 'ID = x-2', 'ID = x-3']); + + wrapper.input.selectByValue('ID = x-1'); + expect(wrapper.input.dropdown()).toBe(false); + expect(wrapper.tokens.list()).toEqual(['ID = x-1']); + }); + + test('fails to create free-text token when pressing enter when free-text selection is disallowed', () => { + const { wrapper } = render({ disableFreeTextFiltering: true }); + + wrapper.input.focus(); + expect(wrapper.input.dropdown()).toBe(true); + expect(wrapper.input.options()).toEqual(['ID', 'Name', 'Active', 'Status', 'Created at']); + + wrapper.input.value('x-2'); + expect(wrapper.input.options()).toEqual(['ID = x-2']); + + wrapper.input.keys(KeyCode.enter); + expect(wrapper.input.dropdown()).toBe(false); + expect(wrapper.tokens.list()).toEqual([]); + }); + + test('fails to create token with non-matched operator when free-text selection is disallowed', () => { + const { wrapper } = render({ disableFreeTextFiltering: true }); + + wrapper.input.focus(); + expect(wrapper.input.dropdown()).toBe(true); + expect(wrapper.input.options()).toEqual(['ID', 'Name', 'Active', 'Status', 'Created at']); + + wrapper.input.value('ID > 2'); + expect(wrapper.input.options()).toEqual([]); + + wrapper.input.keys(KeyCode.enter); + expect(wrapper.input.dropdown()).toBe(false); + expect(wrapper.tokens.list()).toEqual([]); + }); + + test('creates token with non-matched value when free-text selection is disallowed', () => { + const { wrapper } = render({ disableFreeTextFiltering: true }); + + wrapper.input.focus(); + expect(wrapper.input.dropdown()).toBe(true); + expect(wrapper.input.options()).toEqual(['ID', 'Name', 'Active', 'Status', 'Created at']); + + wrapper.input.value('ID = x-?'); + expect(wrapper.input.options()).toEqual(['Use: "ID = x-?"']); + + wrapper.input.keys(KeyCode.enter); + expect(wrapper.input.dropdown()).toBe(false); + expect(wrapper.tokens.list()).toEqual(['ID = x-?']); + }); + + test('creates empty enum token', () => { + const { wrapper } = render({ disableFreeTextFiltering: Math.random() > 0.5 }); + + wrapper.input.focus(); + wrapper.input.value('Status = '); + expect(wrapper.input.dropdown()).toBe(true); + expect(wrapper.input.options()).toEqual(['Active', 'Activating', 'Inactive']); + + wrapper.input.submit(); + expect(wrapper.input.dropdown()).toBe(false); + expect(wrapper.tokens.list()).toEqual(['Status = ']); + }); + + test('creates enum token with two options', () => { + const { wrapper } = render({ disableFreeTextFiltering: Math.random() > 0.5 }); + + wrapper.input.focus(); + wrapper.input.value('Status = '); + expect(wrapper.input.dropdown()).toBe(true); + expect(wrapper.input.options()).toEqual(['Active', 'Activating', 'Inactive']); + + wrapper.input.selectByValue('Active'); + wrapper.input.selectByValue('Inactive'); + + wrapper.input.submit(); + expect(wrapper.input.dropdown()).toBe(false); + expect(wrapper.tokens.list()).toEqual(['Status = Active, Inactive']); + }); + + test('creates enum token with two options using filter', () => { + const { wrapper } = render({ disableFreeTextFiltering: Math.random() > 0.5 }); + + wrapper.input.focus(); + wrapper.input.value('Status = tive'); + expect(wrapper.input.dropdown()).toBe(true); + expect(wrapper.input.options()).toEqual(['Active', 'Inactive']); + + wrapper.input.selectByValue(null); + + wrapper.input.submit(); + expect(wrapper.input.dropdown()).toBe(false); + expect(wrapper.tokens.list()).toEqual(['Status = Active, Inactive']); + }); + + test('creates empty date token', () => { + const { wrapper } = render({ disableFreeTextFiltering: Math.random() > 0.5 }); + + wrapper.input.focus(); + wrapper.input.value('Created at = '); + expect(wrapper.input.dropdown()).toBe(true); + + wrapper.input.submit(); + expect(wrapper.input.dropdown()).toBe(false); + expect(wrapper.tokens.list()).toEqual(['Created at = not specified']); + }); + + test('creates filled date token using custom input', () => { + const { wrapper } = render({ disableFreeTextFiltering: Math.random() > 0.5 }); + + wrapper.input.focus(); + wrapper.input.value('Created at = '); + expect(wrapper.input.dropdown()).toBe(true); + + wrapper.input.date('2021/02/03'); + + wrapper.input.submit(); + expect(wrapper.input.dropdown()).toBe(false); + expect(wrapper.tokens.list()).toEqual(['Created at = [2021-02-03]']); + }); + + test('creates filled date token using enter', () => { + const { wrapper } = render({ disableFreeTextFiltering: Math.random() > 0.5 }); + + wrapper.input.focus(); + wrapper.input.value('Created at = 2021/02/03'); + expect(wrapper.input.dropdown()).toBe(true); + + wrapper.input.keys(KeyCode.enter); + + expect(wrapper.input.dropdown()).toBe(false); + expect(wrapper.tokens.list()).toEqual(['Created at = [2021/02/03]']); + }); +}); diff --git a/src/property-filter/__tests__/property-filter-stories-token-edit-group.test.tsx b/src/property-filter/__tests__/property-filter-stories-token-edit-group.test.tsx new file mode 100644 index 0000000000..d4fa5e4ba1 --- /dev/null +++ b/src/property-filter/__tests__/property-filter-stories-token-edit-group.test.tsx @@ -0,0 +1,126 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PropertyFilterProps } from '../interfaces'; +import { createRenderer } from './stories-components'; + +const defaultProps: Partial = { + filteringProperties: [ + { + key: 'id', + propertyLabel: 'ID', + operators: ['=', '!='], + groupValuesLabel: 'ID values', + }, + { + key: 'name', + propertyLabel: 'Name', + operators: [':', '!:'], + groupValuesLabel: 'Name values', + defaultOperator: ':', + }, + { + key: 'status', + propertyLabel: 'Status', + operators: [ + { operator: '=', tokenType: 'enum' }, + { operator: '!=', tokenType: 'enum' }, + ], + groupValuesLabel: 'Status values', + }, + ], + filteringOptions: [ + { propertyKey: 'id', value: 'x-1' }, + { propertyKey: 'id', value: 'x-2' }, + { propertyKey: 'id', value: 'x-3' }, + { propertyKey: 'status', value: 'Ready' }, + { propertyKey: 'status', value: 'Steady' }, + { propertyKey: 'status', value: 'Go' }, + ], + enableTokenGroups: true, + query: { + operation: 'and', + tokens: [], + tokenGroups: [ + { + operation: 'or', + tokens: [ + { + propertyKey: 'id', + operator: '=', + value: 'x-2', + }, + { + propertyKey: 'name', + operator: ':', + value: 'John', + }, + ], + }, + { + propertyKey: 'status', + operator: '=', + value: ['Ready', 'Go'], + }, + ], + }, +}; + +const render = createRenderer(defaultProps); + +describe('Property filter stories: tokens editing, group', () => { + test('changes operation', () => { + const { wrapper } = render(); + + expect(wrapper.tokens.list()).toEqual(['ID = x-2 or Name : John', 'and Status = Ready, Go']); + + wrapper.tokens.token(2).operation('or'); + expect(wrapper.tokens.list()).toEqual(['ID = x-2 or Name : John', 'or Status = Ready, Go']); + + wrapper.tokens.token(1).nested(2).operation('and'); + expect(wrapper.tokens.list()).toEqual(['ID = x-2 and Name : John', 'or Status = Ready, Go']); + }); + + test('changes second token to a group of two', () => { + const { wrapper } = render(); + + expect(wrapper.tokens.list()).toEqual(['ID = x-2 or Name : John', 'and Status = Ready, Go']); + + wrapper.editor.open(2); + expect(wrapper.editor.dropdown()).toBe(true); + expect(wrapper.editor.form()).toEqual(['Property[Status] Operator[=] Value[Ready, Go]']); + + wrapper.editor.addFilter(); + expect(wrapper.editor.form()).toEqual([ + 'Property[Status] Operator[=] Value[Ready, Go]', + 'Property[Status] Operator[=] Value[]', + ]); + + wrapper.editor.property(2).value('name'); + wrapper.editor.operator(2).value(':'); + wrapper.editor.value(2).autosuggest().input('Jane'); + wrapper.editor.submit(); + expect(wrapper.editor.dropdown()).toBe(false); + expect(wrapper.tokens.list()).toEqual(['ID = x-2 or Name : John', 'and Status = Ready, Go or Name : Jane']); + }); + + test('changes first token from a group of two to single', () => { + const { wrapper } = render(); + + expect(wrapper.tokens.list()).toEqual(['ID = x-2 or Name : John', 'and Status = Ready, Go']); + + wrapper.editor.open(1); + expect(wrapper.editor.dropdown()).toBe(true); + expect(wrapper.editor.form()).toEqual([ + 'Property[ID] Operator[=] Value[x-2]', + 'Property[Name] Operator[:] Value[John]', + ]); + + wrapper.editor.remove(2); + expect(wrapper.editor.form()).toEqual(['Property[ID] Operator[=] Value[x-2]']); + + wrapper.editor.submit(); + expect(wrapper.editor.dropdown()).toBe(false); + expect(wrapper.tokens.list()).toEqual(['ID = x-2', 'and Status = Ready, Go']); + }); +}); diff --git a/src/property-filter/__tests__/property-filter-stories-token-edit-single.test.tsx b/src/property-filter/__tests__/property-filter-stories-token-edit-single.test.tsx new file mode 100644 index 0000000000..f087ca8cde --- /dev/null +++ b/src/property-filter/__tests__/property-filter-stories-token-edit-single.test.tsx @@ -0,0 +1,256 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; + +import DateInput from '../../../lib/components/date-input'; +import { PropertyFilterProps } from '../interfaces'; +import { createRenderer } from './stories-components'; + +const defaultProps: Partial = { + filteringProperties: [ + { + key: 'id', + propertyLabel: 'ID', + operators: ['=', '!='], + groupValuesLabel: 'ID values', + }, + { + key: 'name', + propertyLabel: 'Name', + operators: [':', '!:'], + groupValuesLabel: 'Name values', + defaultOperator: ':', + }, + { + key: 'status', + propertyLabel: 'Status', + operators: [ + { operator: '=', tokenType: 'enum' }, + { operator: '!=', tokenType: 'enum' }, + ], + groupValuesLabel: 'Status values', + }, + { + key: 'creationDate', + propertyLabel: 'Created at', + operators: [ + { + operator: '=', + form: props => props.onChange(detail.value)} />, + format: token => (token ? token : 'not specified'), + }, + ], + groupValuesLabel: 'Creation date values', + }, + ], + filteringOptions: [ + { propertyKey: 'id', value: 'x-1' }, + { propertyKey: 'id', value: 'x-2' }, + { propertyKey: 'id', value: 'x-3' }, + { propertyKey: 'status', value: 'Ready' }, + { propertyKey: 'status', value: 'Steady' }, + { propertyKey: 'status', value: 'Go' }, + ], + query: { + operation: 'and', + tokens: [ + { + propertyKey: 'id', + operator: '=', + value: 'x-2', + }, + { + propertyKey: 'name', + operator: ':', + value: 'John', + }, + { + propertyKey: 'status', + operator: '=', + value: ['Ready', 'Go'], + }, + { + propertyKey: 'creationDate', + operator: '=', + value: '2021', + }, + ], + }, +}; +const render = createRenderer(defaultProps); +const renderAsync = createRenderer(defaultProps, { async: true }); + +describe('Property filter stories: tokens editing, single', () => { + test('changes text token value using available suggestions and custom input', () => { + const { wrapper } = render({}); + + wrapper.editor.open(1); + expect(wrapper.editor.dropdown()).toBe(true); + expect(wrapper.editor.form()).toEqual(['Property[ID] Operator[=Equals] Value[x-2]']); + + wrapper.editor.value().autosuggest().option('x-3'); + expect(wrapper.editor.form()).toEqual(['Property[ID] Operator[=Equals] Value[x-3]']); + + wrapper.editor.value().autosuggest().input('x-?'); + expect(wrapper.editor.form()).toEqual(['Property[ID] Operator[=Equals] Value[x-?]']); + + wrapper.editor.submit(); + expect(wrapper.editor.dropdown()).toBe(false); + expect(wrapper.tokens.list()).toEqual([ + 'ID = x-?', + 'and Name : John', + 'and Status = Ready, Go', + 'and Created at = 2021', + ]); + }); + + test('edits property operator and property type then cancels the change', () => { + const { wrapper } = render({}); + + wrapper.editor.open(2); + expect(wrapper.editor.dropdown()).toBe(true); + expect(wrapper.editor.form()).toEqual(['Property[Name] Operator[:Contains] Value[John]']); + + wrapper.editor.operator().value('!:'); + expect(wrapper.editor.form()).toEqual(['Property[Name] Operator[!:Does not contain] Value[John]']); + + wrapper.editor.property().value(null as any); + expect(wrapper.editor.form()).toEqual(['Property[All properties] Operator[!:Does not contain] Value[]']); + + wrapper.editor.property().value('id'); + expect(wrapper.editor.form()).toEqual(['Property[ID] Operator[=Equals] Value[]']); + + wrapper.editor.cancel(); + expect(wrapper.editor.dropdown()).toBe(false); + expect(wrapper.tokens.list()).toEqual([ + 'ID = x-2', + 'and Name : John', + 'and Status = Ready, Go', + 'and Created at = 2021', + ]); + }); + + test('fails to edit property to free text when free text is disabled', () => { + const { wrapper } = render({ disableFreeTextFiltering: true }); + + wrapper.editor.open(2); + expect(wrapper.editor.dropdown()).toBe(true); + expect(wrapper.editor.form()).toEqual(['Property[Name] Operator[:Contains] Value[John]']); + + expect(wrapper.editor.property().options()).toEqual(['ID', 'Name', 'Status', 'Created at']); + }); + + test('changes enum property value', () => { + const { wrapper } = render({}); + + wrapper.editor.open(3); + expect(wrapper.editor.dropdown()).toBe(true); + expect(wrapper.editor.form()).toEqual(['Property[Status] Operator[=Equals] Value[Ready, Go]']); + + wrapper.editor.value().multiselect().value(['Ready', 'Steady']); + expect(wrapper.editor.form()).toEqual(['Property[Status] Operator[=Equals] Value[Steady, Go]']); + + wrapper.editor.submit(); + expect(wrapper.editor.dropdown()).toBe(false); + expect(wrapper.tokens.list()).toEqual([ + 'ID = x-2', + 'and Name : John', + 'and Status = Go, Steady', + 'and Created at = 2021', + ]); + }); + + test('changes date property value', () => { + const { wrapper } = render({}); + + wrapper.editor.open(4); + expect(wrapper.editor.dropdown()).toBe(true); + expect(wrapper.editor.form()).toEqual(['Property[Created at] Operator[=Equals] Value[2021/]']); + + wrapper.editor.value().dateInput().value('2022/03/04'); + expect(wrapper.editor.form()).toEqual(['Property[Created at] Operator[=Equals] Value[2022/03/04]']); + + wrapper.editor.submit(); + expect(wrapper.editor.dropdown()).toBe(false); + expect(wrapper.tokens.list()).toEqual([ + 'ID = x-2', + 'and Name : John', + 'and Status = Ready, Go', + 'and Created at = 2022-03-04', + ]); + }); + + test('changes operation', () => { + const { wrapper } = render({}); + + expect(wrapper.tokens.list()).toEqual([ + 'ID = x-2', + 'and Name : John', + 'and Status = Ready, Go', + 'and Created at = 2021', + ]); + + wrapper.tokens.token(2).operation('or'); + expect(wrapper.tokens.list()).toEqual([ + 'ID = x-2', + 'or Name : John', + 'or Status = Ready, Go', + 'or Created at = 2021', + ]); + }); + + test('changes async text option', async () => { + const { wrapper } = renderAsync({}); + + wrapper.editor.open(1); + expect(wrapper.editor.dropdown()).toBe(true); + expect(wrapper.editor.form()).toEqual(['Property[ID] Operator[=Equals] Value[x-2]']); + + wrapper.editor.value().autosuggest().input('x-'); + wrapper.editor.value().autosuggest().focus(); + expect(wrapper.editor.value().autosuggest().options()).toEqual([]); + + await new Promise(resolve => setTimeout(resolve, 1)); + expect(wrapper.editor.value().autosuggest().options()).toEqual(['x-1', 'x-2', 'x-3']); + + wrapper.editor.value().autosuggest().option('x-3'); + wrapper.editor.submit(); + expect(wrapper.tokens.list()).toEqual([ + 'ID = x-3', + 'and Name : John', + 'and Status = Ready, Go', + 'and Created at = 2021', + ]); + }); + + test('changes async enum option', async () => { + const { wrapper } = renderAsync({ asyncProperties: false }); + + wrapper.editor.open(3); + expect(wrapper.editor.dropdown()).toBe(true); + expect(wrapper.editor.form()).toEqual(['Property[Status] Operator[=Equals] Value[]']); + + wrapper.editor.value().multiselect().open(); + expect(wrapper.editor.value().multiselect().options()).toEqual([]); + + await new Promise(resolve => setTimeout(resolve, 1)); + expect(wrapper.editor.form()).toEqual(['Property[Status] Operator[=Equals] Value[Ready, Go]']); + expect(wrapper.editor.value().multiselect().options()).toEqual(['Ready', 'Steady', 'Go']); + + wrapper.editor.value().multiselect().filter('Go'); + expect(wrapper.editor.form()).toEqual(['Property[Status] Operator[=Equals] Value[Ready, Go]']); + expect(wrapper.editor.value().multiselect().options()).toEqual(['Go']); + + wrapper.editor.value().multiselect().value([null]); + expect(wrapper.editor.form()).toEqual(['Property[Status] Operator[=Equals] Value[Ready]']); + + wrapper.editor.submit(); + expect(wrapper.tokens.list()).toEqual([ + 'ID = x-2', + 'and Name : John', + 'and Status = Ready', + 'and Created at = 2021', + ]); + }); +}); diff --git a/src/property-filter/__tests__/stories-components.tsx b/src/property-filter/__tests__/stories-components.tsx new file mode 100644 index 0000000000..70a8c31399 --- /dev/null +++ b/src/property-filter/__tests__/stories-components.tsx @@ -0,0 +1,87 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useRef, useState } from 'react'; +import { render as rtlRender } from '@testing-library/react'; + +import PropertyFilter from '../../../lib/components/property-filter'; +import { PropertyFilterProps } from '../interfaces'; +import { createDefaultProps } from './common'; +import { createExtendedWrapper } from './extended-wrapper'; + +// Defines components with state management and async loading behaviors, +// to be used for testing complete user flows. + +export function createRenderer(defaultProps: Partial, options: { async?: boolean } = {}) { + return (props?: Partial) => { + const Component = options.async ? StatefulAsyncPropertyFilter : StatefulPropertyFilter; + rtlRender( + + ); + const wrapper = createExtendedWrapper(); + return { wrapper }; + }; +} + +function StatefulPropertyFilter(props: PropertyFilterProps) { + const [query, setQuery] = useState(props.query); + return ( + { + props.onChange(event); + setQuery(event.detail); + }} + /> + ); +} + +function StatefulAsyncPropertyFilter(props: PropertyFilterProps) { + const timeoutRef = useRef(setTimeout(() => {}, 0)); + const [filteringStatusType, setFilteringStatusType] = useState('pending'); + const [filteringProperties, setFilteringProperties] = useState( + props.asyncProperties ? [] : props.filteringProperties + ); + const [filteringOptions, setFilteringOptions] = useState([]); + + function loadProperties(filteringText: string) { + if (props.asyncProperties) { + setFilteringProperties(props.filteringProperties.filter(p => p.propertyLabel.includes(filteringText))); + } + } + + function loadOptions(filteringProperty: undefined | PropertyFilterProps.FilteringProperty, filteringText: string) { + setFilteringOptions( + (props.filteringOptions ?? []).filter( + o => + (!filteringProperty || o.propertyKey === filteringProperty?.key) && + (o.label || o.value).includes(filteringText) + ) + ); + } + + const onLoadItems: PropertyFilterProps['onLoadItems'] = ({ detail }) => { + clearTimeout(timeoutRef.current); + setFilteringStatusType('loading'); + timeoutRef.current = setTimeout(() => { + setFilteringStatusType('finished'); + loadProperties(detail.filteringText); + loadOptions(detail.filteringProperty, detail.filteringText); + }, 1); + }; + + return ( + + ); +} diff --git a/src/property-filter/interfaces.ts b/src/property-filter/interfaces.ts index 9bafb633a5..bf8fe426f1 100644 --- a/src/property-filter/interfaces.ts +++ b/src/property-filter/interfaces.ts @@ -221,7 +221,7 @@ export interface PropertyFilterProps extends BaseComponentProps, ExpandToViewpor * * `finished` - Indicates that pagination has finished and no more requests are expected. * * `error` - Indicates that an error occurred during fetch. You should use `recoveryText` to enable the user to recover. **/ - filteringStatusType?: DropdownStatusProps.StatusType; + filteringStatusType?: PropertyFilterProps.StatusType; /** * Adds an aria-label to the "Show fewer" button for the token group control. @@ -248,6 +248,7 @@ export namespace PropertyFilterProps { export type FilteringProperty = PropertyFilterProperty; export type FreeTextFiltering = PropertyFilterFreeTextFiltering; export type Query = PropertyFilterQuery; + export type StatusType = DropdownStatusProps.StatusType; export interface LoadItemsDetail { filteringProperty?: FilteringProperty;