diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b64808729b..41d8a74a92e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## [`main`](https://github.com/elastic/eui/tree/main) - Improved `EuiSelectable` keypress scenarios ([#5613](https://github.com/elastic/eui/pull/5613)) +- Converted `FieldValueSelectionFilter` in `EuiSearchBar` to use `EuiSelectable` ([#5387](https://github.com/elastic/eui/issues/5387)) ## [`48.0.0`](https://github.com/elastic/eui/tree/v48.0.0) diff --git a/src-docs/src/views/filter_group/filter_group_example.js b/src-docs/src/views/filter_group/filter_group_example.js index 292fae7d095..5e0c2365993 100644 --- a/src-docs/src/views/filter_group/filter_group_example.js +++ b/src-docs/src/views/filter_group/filter_group_example.js @@ -1,4 +1,5 @@ import React, { Fragment } from 'react'; +import { Link } from 'react-router-dom'; import { GuideSectionTypes } from '../../components'; @@ -74,11 +75,16 @@ export const FilterGroupExample = { text: (

- To provide a long list of grouped filter, use a popover for - filtering an array of passed items. This mostly uses standard - popover mechanics, but the component{' '} - EuiFilterSelectItem is used for the items - themselves. + To provide a long list of grouped filters, we recommend wrapping the + filter button within an{' '} + + EuiPopover + {' '} + and passing the items to a searchable{' '} + + EuiSelectable + + .

Indicating number of filters

diff --git a/src-docs/src/views/filter_group/filter_group_multi.js b/src-docs/src/views/filter_group/filter_group_multi.js index 509e663ca0a..5ea1b757c4b 100644 --- a/src-docs/src/views/filter_group/filter_group_multi.js +++ b/src-docs/src/views/filter_group/filter_group_multi.js @@ -1,22 +1,33 @@ -import React, { useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { EuiPopover, EuiPopoverTitle, - EuiFieldSearch, - EuiFilterSelectItem, - EuiLoadingChart, - EuiSpacer, - EuiIcon, EuiFilterGroup, EuiFilterButton, + EuiSelectable, + EuiSpacer, + EuiSwitch, } from '../../../../src/components'; import { useGeneratedHtmlId } from '../../../../src/services'; export default () => { + const timeoutRef = useRef(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [withLoading, setWithLoading] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + return () => clearTimeout(timeoutRef.current); + }, []); const onButtonClick = () => { + if (withLoading && !isPopoverOpen) { + setIsLoading(true); + timeoutRef.current = setTimeout(() => { + setIsLoading(false); + }, 1500); + } setIsPopoverOpen(!isPopoverOpen); }; @@ -29,56 +40,32 @@ export default () => { }); const [items, setItems] = useState([ - { name: 'Johann Sebastian Bach', checked: 'on' }, - { name: 'Wolfgang Amadeus Mozart', checked: 'on' }, - { name: 'Antonín Dvořák', checked: 'off' }, - { name: 'Dmitri Shostakovich' }, - { name: 'Felix Mendelssohn-Bartholdy' }, - { name: 'Franz Liszt' }, - { name: 'Franz Schubert' }, - { name: 'Frédéric Chopin' }, - { name: 'Georg Friedrich Händel' }, - { name: 'Giuseppe Verdi' }, - { name: 'Gustav Mahler' }, - { name: 'Igor Stravinsky' }, - { name: 'Johannes Brahms' }, - { name: 'Joseph Haydn' }, - { name: 'Ludwig van Beethoven' }, - { name: 'Piotr Illitch Tchaïkovsky' }, - { name: 'Robert Schumann' }, - { name: 'Sergej S. Prokofiew' }, - { name: 'Wolfgang Amadeus Mozart' }, + { label: 'Johann Sebastian Bach', checked: 'on' }, + { label: 'Wolfgang Amadeus Mozart', checked: 'on' }, + { label: 'Antonín Dvořák', checked: 'off' }, + { label: 'Dmitri Shostakovich' }, + { label: 'Felix Mendelssohn-Bartholdy' }, + { label: 'Franz Liszt' }, + { label: 'Franz Schubert' }, + { label: 'Frédéric Chopin' }, + { label: 'Georg Friedrich Händel' }, + { label: 'Giuseppe Verdi' }, + { label: 'Gustav Mahler' }, + { label: 'Igor Stravinsky' }, + { label: 'Johannes Brahms' }, + { label: 'Joseph Haydn' }, + { label: 'Ludwig van Beethoven' }, + { label: 'Piotr Illitch Tchaïkovsky' }, + { label: 'Robert Schumann' }, + { label: 'Sergej S. Prokofiew' }, ]); - function updateItem(index) { - if (!items[index]) { - return; - } - - const newItems = [...items]; - - switch (newItems[index].checked) { - case 'on': - newItems[index].checked = 'off'; - break; - - case 'off': - newItems[index].checked = undefined; - break; - - default: - newItems[index].checked = 'on'; - } - - setItems(newItems); - } - const button = ( item.checked !== 'off').length} hasActiveFilters={!!items.find((item) => item.checked === 'on')} numActiveFilters={items.filter((item) => item.checked === 'on').length} > @@ -87,49 +74,45 @@ export default () => { ); return ( - - - - - -

- {items.map((item, index) => ( - updateItem(index)} - > - {item.name} - - ))} - {/* - Use when loading items initially - */} -
-
- - -

Loading filters

-
-
- {/* - Use when no results are returned - */} -
-
- - -

No filters found

-
-
-
- - + <> + setWithLoading(e.target.checked)} + label="Simulate dynamic loading" + /> + + + + setItems(newOptions)} + isLoading={isLoading} + loadingMessage="Loading filters" + emptyMessage="No filters available" + noMatchesMessage="No filters found" + > + {(list, search) => ( +
+ {search} + {list} +
+ )} +
+
+
+ ); }; diff --git a/src-docs/src/views/selectable/selectable_example.js b/src-docs/src/views/selectable/selectable_example.js index b1301294ed1..c23b1a6c000 100644 --- a/src-docs/src/views/selectable/selectable_example.js +++ b/src-docs/src/views/selectable/selectable_example.js @@ -225,11 +225,16 @@ export const SelectableExample = {

Width and height

The width has been made to always be 100% of its container, - including stretching the search input. By default, the height is - capped at showing up to 7.5 items. It shows half of the last one to - help indicate that there are more options to scroll to. To stretch - the box to fill its container, pass 'full' to the{' '} - height prop. + including stretching the search input. When used inside of{' '} + + EuiPopover + + , we recommend setting a width (or min/max values) via CSS on the + element containing the list to avoid expansion and contraction. By + default, the height is capped at showing up to 7.5 items. It shows + half of the last one to help indicate that there are more options to + scroll to. To stretch the box to fill its container, pass + 'full' to the height prop.

Flexbox

diff --git a/src/components/search_bar/filters/__snapshots__/field_value_selection_filter.test.tsx.snap b/src/components/search_bar/filters/__snapshots__/field_value_selection_filter.test.tsx.snap deleted file mode 100644 index 843b6ba9990..00000000000 --- a/src/components/search_bar/filters/__snapshots__/field_value_selection_filter.test.tsx.snap +++ /dev/null @@ -1,431 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`FieldValueSelectionFilter active - field is global 1`] = ` - - Tag - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - isOpen={false} - ownFocus={true} - panelClassName="euiFilterGroup__popoverPanel" - panelPaddingSize="none" -> -

-
- - -

- Loading... -

-
-
- -`; - -exports[`FieldValueSelectionFilter active - fields in options 1`] = ` - - Tag - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - isOpen={false} - ownFocus={true} - panelClassName="euiFilterGroup__popoverPanel" - panelPaddingSize="none" -> -
-
- - -

- Loading... -

-
-
-
-`; - -exports[`FieldValueSelectionFilter inactive - field is global 1`] = ` - - Tag - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - isOpen={false} - ownFocus={true} - panelClassName="euiFilterGroup__popoverPanel" - panelPaddingSize="none" -> -
- - feature - - - Text - - -
- bug -
-
-
-
-`; - -exports[`FieldValueSelectionFilter inactive - fields in options 1`] = ` - - Tag - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - isOpen={false} - ownFocus={true} - panelClassName="euiFilterGroup__popoverPanel" - panelPaddingSize="none" -> -
- - feature - - - Text - - -
- bug -
-
-
-
-`; - -exports[`FieldValueSelectionFilter render - all configurations 1`] = ` - - Tag - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - isOpen={false} - ownFocus={true} - panelClassName="euiFilterGroup__popoverPanel" - panelPaddingSize="none" -> -
-
- - -

- loading... -

-
-
-
-`; - -exports[`FieldValueSelectionFilter render - fields in options 1`] = ` - - Tag - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - isOpen={false} - ownFocus={true} - panelClassName="euiFilterGroup__popoverPanel" - panelPaddingSize="none" -> -
- - feature - - - Text - - -
- bug -
-
-
-
-`; - -exports[`FieldValueSelectionFilter render - multi-select OR 1`] = ` - - Tag - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - isOpen={false} - ownFocus={true} - panelClassName="euiFilterGroup__popoverPanel" - panelPaddingSize="none" -> -
-
- - -

- loading... -

-
-
-
-`; - -exports[`FieldValueSelectionFilter render - options as a function 1`] = ` - - Tag - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - isOpen={false} - ownFocus={true} - panelClassName="euiFilterGroup__popoverPanel" - panelPaddingSize="none" -> -
-
- - -

- Loading... -

-
-
-
-`; - -exports[`FieldValueSelectionFilter render - options as an array 1`] = ` - - Tag - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - isOpen={false} - ownFocus={true} - panelClassName="euiFilterGroup__popoverPanel" - panelPaddingSize="none" -> -
- - feature - - - Text - - -
- bug -
-
-
-
-`; diff --git a/src/components/search_bar/filters/field_value_selection_filter.test.tsx b/src/components/search_bar/filters/field_value_selection_filter.spec.tsx similarity index 62% rename from src/components/search_bar/filters/field_value_selection_filter.test.tsx rename to src/components/search_bar/filters/field_value_selection_filter.spec.tsx index d279137b3ee..23181d23e45 100644 --- a/src/components/search_bar/filters/field_value_selection_filter.test.tsx +++ b/src/components/search_bar/filters/field_value_selection_filter.spec.tsx @@ -8,15 +8,29 @@ import React from 'react'; import { requiredProps } from '../../../test'; -import { shallow } from 'enzyme'; import { FieldValueSelectionFilter, FieldValueSelectionFilterProps, } from './field_value_selection_filter'; import { Query } from '../query'; +const staticOptions = [ + { + value: 'feature', + }, + { + value: 'test', + name: 'Text', + }, + { + value: 'bug', + name: 'Bug', + view:
bug
, + }, +]; + describe('FieldValueSelectionFilter', () => { - test('render - options as a function', () => { + it('allows options as a function', () => { const props: FieldValueSelectionFilterProps = { ...requiredProps, index: 0, @@ -26,16 +40,18 @@ describe('FieldValueSelectionFilter', () => { type: 'field_value_selection', field: 'tag', name: 'Tag', - options: () => Promise.resolve([]), + options: () => Promise.resolve(staticOptions), }, }; - const component = shallow(); - - expect(component).toMatchSnapshot(); + cy.mount(); + cy.get('button').click(); + cy.get('[data-test-subj="euiSelectableList"] li') + .first() + .should('have.attr', 'title', 'feature'); }); - test('render - options as an array', () => { + it('allows options as an array', () => { const props: FieldValueSelectionFilterProps = { ...requiredProps, index: 0, @@ -45,29 +61,18 @@ describe('FieldValueSelectionFilter', () => { type: 'field_value_selection', field: 'tag', name: 'Tag', - options: [ - { - value: 'feature', - }, - { - value: 'test', - name: 'Text', - }, - { - value: 'bug', - name: 'Bug', - view:
bug
, - }, - ], + options: staticOptions, }, }; - const component = shallow(); - - expect(component).toMatchSnapshot(); + cy.mount(); + cy.get('button').click(); + cy.get('[data-test-subj="euiSelectableList"] li') + .eq(1) + .should('have.attr', 'title', 'Text'); }); - test('render - fields in options', () => { + it('allows fields in options', () => { const props: FieldValueSelectionFilterProps = { ...requiredProps, index: 0, @@ -96,12 +101,14 @@ describe('FieldValueSelectionFilter', () => { }, }; - const component = shallow(); - - expect(component).toMatchSnapshot(); + cy.mount(); + cy.get('button').click(); + cy.get('[data-test-subj="euiSelectableList"] li') + .eq(2) + .should('have.attr', 'title', 'Bug'); }); - test('render - all configurations', () => { + it('allows all configurations', () => { const props: FieldValueSelectionFilterProps = { ...requiredProps, index: 0, @@ -120,12 +127,15 @@ describe('FieldValueSelectionFilter', () => { }, }; - const component = shallow(); - - expect(component).toMatchSnapshot(); + cy.mount(); + cy.get('button').click(); + cy.get('[data-test-subj="euiSelectableMessage"]').should( + 'have.text', + 'oops...' + ); }); - test('render - multi-select OR', () => { + it('uses multi-select OR', () => { const props: FieldValueSelectionFilterProps = { ...requiredProps, index: 0, @@ -144,12 +154,15 @@ describe('FieldValueSelectionFilter', () => { }, }; - const component = shallow(); - - expect(component).toMatchSnapshot(); + cy.mount(); + cy.get('button').click(); + cy.get('[data-test-subj="euiSelectableMessage"]').should( + 'have.text', + 'oops...' + ); }); - test('inactive - field is global', () => { + it('has inactive filters, field is global', () => { const props: FieldValueSelectionFilterProps = { ...requiredProps, index: 0, @@ -159,29 +172,18 @@ describe('FieldValueSelectionFilter', () => { type: 'field_value_selection', field: 'tag', name: 'Tag', - options: [ - { - value: 'feature', - }, - { - value: 'test', - name: 'Text', - }, - { - value: 'bug', - name: 'Bug', - view:
bug
, - }, - ], + options: staticOptions, }, }; - const component = shallow(); - - expect(component).toMatchSnapshot(); + cy.mount(); + cy.get('button').click(); + cy.get('[data-test-subj="euiSelectableList"] li') + .first() + .should('have.attr', 'title', 'feature'); }); - test('active - field is global', () => { + it('has active filters, field is global', () => { const props: FieldValueSelectionFilterProps = { ...requiredProps, index: 0, @@ -191,29 +193,19 @@ describe('FieldValueSelectionFilter', () => { type: 'field_value_selection', field: 'tag', name: 'Tag', - options: [ - { - value: 'feature', - }, - { - value: 'test', - name: 'Text', - }, - { - value: 'bug', - name: 'Bug', - view:
bug
, - }, - ], + options: staticOptions, }, }; - const component = shallow(); - - expect(component).toMatchSnapshot(); + cy.mount(); + cy.get('button').click(); + cy.get('.euiNotificationBadge').should('not.be.undefined'); + cy.get('[data-test-subj="euiSelectableList"] li') + .first() + .should('have.attr', 'title', 'Bug'); }); - test('inactive - fields in options', () => { + it('has inactive filters, fields in options', () => { const props: FieldValueSelectionFilterProps = { ...requiredProps, index: 0, @@ -242,12 +234,14 @@ describe('FieldValueSelectionFilter', () => { }, }; - const component = shallow(); - - expect(component).toMatchSnapshot(); + cy.mount(); + cy.get('button').click(); + cy.get('[data-test-subj="euiSelectableList"] li') + .eq(2) + .should('have.attr', 'title', 'Bug'); }); - test('active - fields in options', () => { + it('has active filters, fields in options', () => { const props: FieldValueSelectionFilterProps = { ...requiredProps, index: 0, @@ -276,8 +270,11 @@ describe('FieldValueSelectionFilter', () => { }, }; - const component = shallow(); - - expect(component).toMatchSnapshot(); + cy.mount(); + cy.get('button').click(); + cy.get('.euiNotificationBadge').should('not.be.undefined'); + cy.get('[data-test-subj="euiSelectableList"] li') + .eq(0) + .should('have.attr', 'title', 'Bug'); }); }); diff --git a/src/components/search_bar/filters/field_value_selection_filter.tsx b/src/components/search_bar/filters/field_value_selection_filter.tsx index 2806889ef27..d908c24884f 100644 --- a/src/components/search_bar/filters/field_value_selection_filter.tsx +++ b/src/components/search_bar/filters/field_value_selection_filter.tsx @@ -6,15 +6,12 @@ * Side Public License, v 1. */ -import React, { Component, ReactElement, ReactNode } from 'react'; +import React, { Component, ReactNode } from 'react'; import { isArray, isNil } from '../../../services/predicate'; -import { keys } from '../../../services'; +import { ExclusiveUnion } from '../../common'; import { EuiPopover, EuiPopoverTitle } from '../../popover'; -import { EuiFieldSearch } from '../../form/field_search'; -import { EuiFilterButton, EuiFilterSelectItem } from '../../filter_group'; -import { EuiLoadingChart } from '../../loading'; -import { EuiSpacer } from '../../spacer'; -import { EuiIcon } from '../../icon'; +import { EuiFilterButton } from '../../filter_group'; +import { EuiSelectable, EuiSelectableProps } from '../../selectable'; import { Query } from '../query'; import { Clause, Operator, OperatorType, Value } from '../query/ast'; @@ -86,9 +83,6 @@ export class FieldValueSelectionFilter extends Component< FieldValueSelectionFilterProps, State > { - private readonly selectItems: EuiFilterSelectItem[]; - private searchInput: HTMLInputElement | null = null; - constructor(props: FieldValueSelectionFilterProps) { super(props); const { options } = props.config; @@ -99,8 +93,6 @@ export class FieldValueSelectionFilter extends Component< shown: options, } : null; - - this.selectItems = []; this.state = { popoverOpen: false, error: null, @@ -290,34 +282,6 @@ export class FieldValueSelectionFilter extends Component< } } - onKeyDown( - index: number, - event: - | React.KeyboardEvent - | React.KeyboardEvent - ) { - switch (event.key) { - case keys.ARROW_DOWN: - if (index < this.selectItems.length - 1) { - event.preventDefault(); - this.selectItems[index + 1].focus(); - } - break; - - case keys.ARROW_UP: - if (index < 0) { - return; // it's coming from the search box... nothing to do... nowhere to go - } - if (index === 0 && this.searchInput) { - event.preventDefault(); - this.searchInput.focus(); - } else if (index > 0) { - event.preventDefault(); - this.selectItems[index - 1].focus(); - } - } - } - resolveMultiSelect(): MultiSelect { const { config } = this.props; return !isNil(config.multiSelect) @@ -358,13 +322,59 @@ export class FieldValueSelectionFilter extends Component< ); - const searchBox = this.renderSearchBox(); - const content = this.renderContent( - config.field, - query, - config, - multiSelect - ); + const items = this.state.options + ? this.state.options.shown.map((option) => { + const optionField = option.field || config.field; + + if (optionField == null) { + throw new Error( + 'option.field or field should be provided in ' + ); + } + + const clause = + multiSelect === 'or' + ? query.getOrFieldClause(optionField, option.value) + : query.getSimpleFieldClause(optionField, option.value); + + const label = this.resolveOptionName(option); + + const checked = this.resolveChecked(clause); + return { + label, + checked, + data: { + view: option.view ?? label, + value: option.value, + optionField, + }, + }; + }) + : []; + + const threshold = config.searchThreshold || defaults.config.searchThreshold; + const isOverSearchThreshold = + this.state.options && this.state.options.all.length >= threshold; + + let searchProps: ExclusiveUnion< + { searchable: false }, + { + searchable: true; + searchProps: EuiSelectableProps['searchProps']; + } + > = { + searchable: false, + }; + + if (isOverSearchThreshold) { + searchProps = { + searchable: true, + searchProps: { + compressed: true, + disabled: this.state.error != null, + }, + }; + } return ( - {searchBox} - {content} + > + singleSelection={!multiSelect} + aria-label={config.name} + options={items} + renderOption={(option) => option.view} + isLoading={isNil(this.state.options)} + loadingMessage={ + config.loadingMessage || defaults.config.loadingMessage + } + emptyMessage={ + config.noOptionsMessage || defaults.config.noOptionsMessage + } + errorMessage={this.state.error} + noMatchesMessage={ + config.noOptionsMessage || defaults.config.noOptionsMessage + } + listProps={{ + isVirtualized: isOverSearchThreshold || false, + }} + onChange={(options) => { + const diff = items.find( + (item, index) => item.checked !== options[index].checked + ); + if (diff) { + this.onOptionClick( + diff.data.optionField, + diff.data.value, + diff.checked + ); + } + }} + {...searchProps} + > + {(list, search) => ( + <> + {isOverSearchThreshold && ( + {search} + )} + {list} + + )} + ); } - renderSearchBox() { - const threshold = - this.props.config.searchThreshold || defaults.config.searchThreshold; - if (this.state.options && this.state.options.all.length >= threshold) { - const disabled = this.state.error != null; - return ( - - (this.searchInput = ref)} - disabled={disabled} - incremental={true} - onSearch={(query) => this.filterOptions(query)} - onKeyDown={this.onKeyDown.bind(this, -1)} - compressed - /> - - ); - } - } - - renderContent( - field: string | undefined, - query: Query, - config: FieldValueSelectionFilterConfigType, - multiSelect: MultiSelect - ) { - if (this.state.error) { - return this.renderError(this.state.error); - } - if (isNil(this.state.options)) { - return this.renderLoader(); - } - if (this.state.options.shown.length === 0) { - return this.renderNoOptions(); - } - - if (this.state.options == null) { - return; - } - - const items: ReactElement[] = []; - - this.state.options.shown.forEach((option, index) => { - const optionField = option.field || field; - - if (optionField == null) { - throw new Error( - 'option.field or field should be provided in ' - ); - } - - const clause = - multiSelect === 'or' - ? query.getOrFieldClause(optionField, option.value) - : query.getSimpleFieldClause(optionField, option.value); - - const checked = this.resolveChecked(clause); - const onClick = () => { - // clicking a checked item will uncheck it and effective remove the filter (value = undefined) - this.onOptionClick(optionField, option.value, checked); - }; - - const item = ( - (this.selectItems[index] = ref!)} - onKeyDown={this.onKeyDown.bind(this, index)} - > - {option.view ? option.view : this.resolveOptionName(option)} - - ); - - items.push(item); - }); - - return
{items}
; - } - resolveChecked(clause: Clause | undefined): 'on' | 'off' | undefined { if (clause) { return Query.isMust(clause) ? 'on' : 'off'; } } - renderLoader() { - const message = - this.props.config.loadingMessage || defaults.config.loadingMessage; - return ( -
-
- - -

{message}

-
-
- ); - } - - renderError(message: string) { - return ( -
-
- - -

{message}

-
-
- ); - } - - renderNoOptions() { - const message = - this.props.config.noOptionsMessage || defaults.config.noOptionsMessage; - return ( -
-
- - -

{message}

-
-
- ); - } - isActiveField(field: string | undefined): boolean { if (typeof field !== 'string') { return false; diff --git a/src/components/selectable/__snapshots__/selectable.test.tsx.snap b/src/components/selectable/__snapshots__/selectable.test.tsx.snap index 7c1b1c129da..a95980e42fc 100644 --- a/src/components/selectable/__snapshots__/selectable.test.tsx.snap +++ b/src/components/selectable/__snapshots__/selectable.test.tsx.snap @@ -12,6 +12,7 @@ exports[`EuiSelectable custom options with data 1`] = `

extends Component< {messageContent ? ( @@ -708,6 +709,7 @@ export class EuiSelectable extends Component< ) : ( + data-test-subj="euiSelectableList" key="list" options={options} visibleOptions={visibleOptions}