diff --git a/packages/eui/changelogs/upcoming/7958.md b/packages/eui/changelogs/upcoming/7958.md new file mode 100644 index 00000000000..92b4aaf4f32 --- /dev/null +++ b/packages/eui/changelogs/upcoming/7958.md @@ -0,0 +1 @@ +- Updated `EuiSearchBar`'s `field_value_selection` filter type with a new `autoSortOptions` config, allowing consumers to configure whether or not selected options are automatically sorted to the top of the filter list diff --git a/packages/eui/src-docs/src/views/search_bar/props_info.js b/packages/eui/src-docs/src/views/search_bar/props_info.js index 6616f9270f0..8172e69d545 100644 --- a/packages/eui/src-docs/src/views/search_bar/props_info.js +++ b/packages/eui/src-docs/src/views/search_bar/props_info.js @@ -224,6 +224,12 @@ export const propsInfo = { required: true, type: { name: '#FieldValueOption[] | () => #FieldValueOption[]' }, }, + available: { + description: + 'A callback that defines whether this filter is currently available', + required: false, + type: { name: '() => boolean' }, + }, filterWith: { description: 'Specify how user input in the option dropdown will filter the available options.', @@ -249,19 +255,12 @@ export const propsInfo = { defaultValue: { value: 'true ("and")' }, type: { name: 'boolean | "or" | "and"' }, }, - loadingMessage: { - description: - 'The message that will be shown while loading the options', - required: false, - defaultValue: { value: 'Loading...' }, - type: { name: 'string' }, - }, - noOptionsMessage: { + operator: { description: - 'The message that will be shown when no options are found', + 'What operator should be used when adding selection to the search bar.', required: false, - defaultValue: { value: 'No options found' }, - type: { name: 'string' }, + defaultValue: { value: 'eq' }, + type: { name: 'eq | exact | gt | gte | lt | lte' }, }, searchThreshold: { description: @@ -271,25 +270,33 @@ export const propsInfo = { defaultValue: { value: '10' }, type: { name: 'number' }, }, - available: { + noOptionsMessage: { description: - 'A callback that defines whether this filter is currently available', + 'The message that will be shown when no options are found', required: false, - type: { name: '() => boolean' }, + defaultValue: { value: 'No options found' }, + type: { name: 'string' }, + }, + loadingMessage: { + description: + 'The message that will be shown while loading the options', + required: false, + defaultValue: { value: 'Loading...' }, + type: { name: 'string' }, }, autoClose: { description: - 'Should the dropdown close after the user selects a value. If not explicitly passed, will auto-close for single selection and remain open for multi-selection.', + 'Whether the dropdown should close after the user selects a value. If not explicitly passed, will auto-close for single selection and remain open for multi-selection.', required: false, defaultValue: { value: 'true' }, type: { name: 'boolean' }, }, - operator: { + autoSortOptions: { description: - 'What operator should be used when adding selection to the search bar.', + 'Whether selected options (on and off) should be shown at the top of the filters list', required: false, - defaultValue: { value: 'eq' }, - type: { name: 'eq | exact | gt | gte | lt | lte' }, + defaultValue: { value: 'true' }, + type: { name: 'boolean' }, }, }, }, diff --git a/packages/eui/src-docs/src/views/search_bar/search_bar_filters.js b/packages/eui/src-docs/src/views/search_bar/search_bar_filters.js index a615d5c96bf..bbcb3814a1f 100644 --- a/packages/eui/src-docs/src/views/search_bar/search_bar_filters.js +++ b/packages/eui/src-docs/src/views/search_bar/search_bar_filters.js @@ -204,6 +204,7 @@ export const SearchBarFilters = () => { value: tag.name, view: {tag.name}, })), + autoSortOptions: false, }, { type: 'custom_component', diff --git a/packages/eui/src/components/search_bar/filters/field_value_selection_filter.spec.tsx b/packages/eui/src/components/search_bar/filters/field_value_selection_filter.spec.tsx index 0189dd6e18c..7f1f3464bea 100644 --- a/packages/eui/src/components/search_bar/filters/field_value_selection_filter.spec.tsx +++ b/packages/eui/src/components/search_bar/filters/field_value_selection_filter.spec.tsx @@ -15,6 +15,7 @@ import { requiredProps } from '../../../test'; import { FieldValueSelectionFilter, FieldValueSelectionFilterProps, + FieldValueSelectionFilterConfigType, } from './field_value_selection_filter'; import { Query } from '../query'; @@ -34,6 +35,29 @@ const staticOptions = [ ]; describe('FieldValueSelectionFilter', () => { + const FieldValueSelectionFilterWithState = ( + config: Partial + ) => { + const [query, setQuery] = useState(Query.parse('')); + const onChange = (newQuery: Query) => setQuery(newQuery); + + const props: FieldValueSelectionFilterProps = { + ...requiredProps, + index: 0, + onChange, + query, + config: { + type: 'field_value_selection', + field: 'tag', + name: 'Tag', + options: staticOptions, + ...config, + }, + }; + + return ; + }; + it('allows options as a function', () => { const props: FieldValueSelectionFilterProps = { ...requiredProps, @@ -140,31 +164,6 @@ describe('FieldValueSelectionFilter', () => { }); describe('multi-select testing', () => { - const FieldValueSelectionFilterWithState = ({ - multiSelect, - }: { - multiSelect: 'or' | boolean; - }) => { - const [query, setQuery] = useState(Query.parse('')); - const onChange = (newQuery: Query) => setQuery(newQuery); - - const props: FieldValueSelectionFilterProps = { - ...requiredProps, - index: 0, - onChange, - query, - config: { - type: 'field_value_selection', - field: 'tag', - name: 'Tag', - multiSelect, - options: staticOptions, - }, - }; - - return ; - }; - it('uses multi-select OR', () => { cy.mount(); cy.get('button').click(); @@ -226,33 +225,6 @@ describe('FieldValueSelectionFilter', () => { }); describe('auto-close testing', () => { - const FieldValueSelectionFilterWithState = ({ - autoClose, - multiSelect, - }: { - autoClose: undefined | boolean; - multiSelect: 'or' | boolean; - }) => { - const [query, setQuery] = useState(Query.parse('')); - const onChange = (newQuery: Query) => setQuery(newQuery); - - const props: FieldValueSelectionFilterProps = { - ...requiredProps, - index: 0, - onChange, - query, - config: { - type: 'field_value_selection', - field: 'tag', - name: 'Tag', - multiSelect, - autoClose, - options: staticOptions, - }, - }; - - return ; - }; const selectFilter = () => { // Open popover cy.get('button').click(); @@ -338,6 +310,37 @@ describe('FieldValueSelectionFilter', () => { }); }); + describe('autoSortOptions', () => { + const getOptions = () => cy.get('.euiSelectableListItem'); + + it('sorts selected options to the top by default', () => { + cy.mount(); + cy.get('button').click(); + getOptions().should('have.length', 3); + + getOptions().last().should('have.attr', 'title', 'Bug').click(); + // Should have moved to the top of the list and retained active focus + getOptions() + .first() + .should('have.attr', 'title', 'Bug') + .should('have.attr', 'aria-checked', 'true') + .should('have.attr', 'aria-selected', 'true'); + }); + + it('does not sort selected options to the top when set to false', () => { + cy.mount(); + cy.get('button').click(); + getOptions().should('have.length', 3); + + getOptions().last().should('have.attr', 'title', 'Bug').click(); + getOptions() + .last() + .should('have.attr', 'title', 'Bug') + .should('have.attr', 'aria-checked', 'true') + .should('have.attr', 'aria-selected', 'true'); + }); + }); + it('has inactive filters, field is global', () => { const props: FieldValueSelectionFilterProps = { ...requiredProps, @@ -453,4 +456,66 @@ describe('FieldValueSelectionFilter', () => { .eq(0) .should('have.attr', 'title', 'Bug'); }); + + it('caches options if options is a function and config.cache is set', () => { + // Note: cy.clock()/cy.tick() doesn't currently work in Cypress component testing :T + // We should use that instead of cy.wait once https://github.com/cypress-io/cypress/issues/28846 is fixed + const props: FieldValueSelectionFilterProps = { + index: 0, + onChange: () => {}, + query: Query.parse(''), + config: { + type: 'field_value_selection', + field: 'tag', + name: 'Tag', + cache: 5000, // Cache the loaded tags for 5 seconds + options: () => + new Promise((resolve) => { + setTimeout(() => { + resolve(staticOptions); + }, 1000); // Spoof 1 second load time + }), + }, + }; + cy.spy(props.config, 'options'); + + const reducedTimeout = { timeout: 10 }; + const assertIsLoading = (expected?: Function) => { + cy.get('.euiSelectableListItem', reducedTimeout).should('have.length', 0); + cy.get('[data-test-subj="euiSelectableMessage"]', reducedTimeout) + .should('have.text', 'Loading options') + .then(() => { + expected?.(); + }); + }; + const assertIsLoaded = (expected?: Function) => { + cy.get('.euiSelectableListItem', reducedTimeout).should('have.length', 3); + cy.get('[data-test-subj="euiSelectableMessage"]', reducedTimeout) + .should('not.exist') + .then(() => { + expected?.(); + }); + }; + + cy.mount(); + cy.get('button').click(); + assertIsLoading(); + + // Wait out the async options loader + cy.wait(1000); + assertIsLoaded(() => expect(props.config.options).to.be.calledOnce); + + // Close and re-open the popover + cy.get('button').click(); + cy.get('button').click(); + + // Cached options should immediately repopulate + assertIsLoaded(() => expect(props.config.options).to.be.calledOnce); + + // Wait out the remainder of the cache, loading state should initiate again + cy.get('button').click(); + cy.wait(5000); + cy.get('button').click(); + assertIsLoading(() => expect(props.config.options).to.be.calledTwice); + }); }); diff --git a/packages/eui/src/components/search_bar/filters/field_value_selection_filter.tsx b/packages/eui/src/components/search_bar/filters/field_value_selection_filter.tsx index 9ceef0f9a6b..152f5b66c6b 100644 --- a/packages/eui/src/components/search_bar/filters/field_value_selection_filter.tsx +++ b/packages/eui/src/components/search_bar/filters/field_value_selection_filter.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { Component, ReactNode } from 'react'; +import React, { Component, ReactNode, createRef } from 'react'; import { RenderWithEuiTheme } from '../../../services'; import { isArray, isNil } from '../../../services/predicate'; @@ -53,6 +53,7 @@ export interface FieldValueSelectionFilterConfigType { available?: () => boolean; autoClose?: boolean; operator?: OperatorType; + autoSortOptions?: boolean; } export interface FieldValueSelectionFilterProps { @@ -67,6 +68,7 @@ const defaults = { multiSelect: true, filterWith: 'prefix', searchThreshold: 10, + autoSortOptions: true, }, }; @@ -74,32 +76,36 @@ interface State { popoverOpen: boolean; error: string | null; options: { - all: FieldValueOptionType[]; - shown: FieldValueOptionType[]; + unsorted: FieldValueOptionType[]; + sorted: FieldValueOptionType[]; } | null; cachedOptions?: FieldValueOptionType[] | null; - activeItems: FieldValueOptionType[]; + activeItemsCount: number; + lastCheckedValue?: Value; } export class FieldValueSelectionFilter extends Component< FieldValueSelectionFilterProps, State > { + selectableClassRef = createRef(); + cacheTimeout: ReturnType | undefined; + constructor(props: FieldValueSelectionFilterProps) { super(props); const { options } = props.config; const preloadedOptions = isArray(options) ? { - all: options, - shown: options, + unsorted: options, + sorted: options, } : null; this.state = { popoverOpen: false, error: null, options: preloadedOptions, - activeItems: [], + activeItemsCount: 0, }; } @@ -123,122 +129,89 @@ export class FieldValueSelectionFilter extends Component< }); } - loadOptions() { - const loader = this.resolveOptionsLoader(); + loadOptions = async () => { + let loadedOptions: FieldValueOptionType[]; this.setState({ options: null, error: null }); - loader() - .then((options) => { - const items: { - on: FieldValueOptionType[]; - off: FieldValueOptionType[]; - rest: FieldValueOptionType[]; - } = { - on: [], - off: [], - rest: [], - }; - - const { query, config } = this.props; - - const multiSelect = this.resolveMultiSelect(); - - if (options) { - options.forEach((op) => { - const optionField = op.field || config.field; - if (optionField) { - const clause = - multiSelect === 'or' - ? query.getOrFieldClause(optionField, op.value) - : query.getSimpleFieldClause(optionField, op.value); - const checked = this.resolveChecked(clause); - if (!checked) { - items.rest.push(op); - } else if (checked === 'on') { - items.on.push(op); - } else { - items.off.push(op); - } - } - return; - }); - } - this.setState({ - error: null, - activeItems: items.on, - options: { - all: options, - shown: [...items.on, ...items.off, ...items.rest], - }, - }); - }) - .catch(() => { - this.setState({ options: null, error: 'Could not load options' }); - }); - } - - filterOptions(q = '') { - this.setState((prevState) => { - if (isNil(prevState.options)) { - return {}; + const { options, cache } = this.props.config; + try { + if (isArray(options)) { + // Synchronous options, already loaded + loadedOptions = options; + } else { + // Async options loader fn, potentially with a cache + loadedOptions = this.state.cachedOptions ?? (await options()); + + // If a cache time is set, populate the cache and schedule a cache reset + if (cache != null && cache > 0) { + this.setState({ cachedOptions: loadedOptions }); + this.cacheTimeout = setTimeout(() => { + this.setState({ cachedOptions: null }); + }, cache); + } } - - const predicate = this.getOptionFilter(); - - return { - ...prevState, - options: { - ...prevState.options, - shown: prevState.options.all.filter((option, i, options) => { - const name = this.resolveOptionName(option).toLowerCase(); - const query = q.toLowerCase(); - return predicate(name, query, options); - }), - }, - }; - }); - } - - getOptionFilter(): OptionsFilter { - const filterWith = - this.props.config.filterWith || defaults.config.filterWith; - - if (typeof filterWith === 'function') { - return filterWith; + } catch { + return this.setState({ options: null, error: 'Could not load options' }); } - if (filterWith === 'includes') { - return (name, query) => name.includes(query); - } + const items: Record = { + on: [], + off: [], + rest: [], + }; - return (name, query) => name.startsWith(query); - } + const { query, config } = this.props; - resolveOptionsLoader: () => OptionsLoader = () => { - const options = this.props.config.options; - if (isArray(options)) { - return () => Promise.resolve(options); + if (loadedOptions) { + loadedOptions.forEach((op) => { + const optionField = op.field || config.field; + if (optionField) { + const clause = + this.multiSelect === 'or' + ? query.getOrFieldClause(optionField, op.value) + : query.getSimpleFieldClause(optionField, op.value); + const checked = this.resolveChecked(clause); + if (!checked) { + items.rest.push(op); + } else if (checked === 'on') { + items.on.push(op); + } else { + items.off.push(op); + } + } + return; + }); } - return () => { - const cachedOptions = this.state.cachedOptions; - if (cachedOptions) { - return Promise.resolve(cachedOptions); - } - - return (options as OptionsLoader)().then((opts) => { - // If a cache time is set, populate the cache and also schedule a - // cache reset. - if (this.props.config.cache != null && this.props.config.cache > 0) { - this.setState({ cachedOptions: opts }); - setTimeout(() => { - this.setState({ cachedOptions: null }); - }, this.props.config.cache); - } + this.setState( + { + error: null, + activeItemsCount: items.on.length, + options: { + unsorted: loadedOptions, + sorted: [...items.on, ...items.off, ...items.rest], + }, + }, + this.scrollToAutoSortedOption + ); + }; - return opts; - }); - }; + scrollToAutoSortedOption = () => { + if (!this.autoSortOptions) return; + + const { lastCheckedValue, options } = this.state; + if (lastCheckedValue) { + const sortedIndex = options!.sorted.findIndex( + (option) => option.value === lastCheckedValue + ); + if (sortedIndex >= 0) { + // EuiSelectable should automatically handle scrolling its list to the new index + this.selectableClassRef.current?.setState({ + activeOptionIndex: sortedIndex, + }); + } + this.setState({ lastCheckedValue: undefined }); + } }; resolveOptionName(option: FieldValueOptionType) { @@ -250,20 +223,23 @@ export class FieldValueSelectionFilter extends Component< value: Value, checked?: Omit ) { - const multiSelect = this.resolveMultiSelect(); const { config: { autoClose, operator = Operator.EQ }, } = this.props; + if (checked && this.autoSortOptions) { + this.setState({ lastCheckedValue: value }); + } + // If the consumer explicitly sets `autoClose`, always defer to that. // Otherwise, default to auto-closing for single selections and leaving the // popover open for multi-select (so users can continue selecting options) - const shouldClosePopover = autoClose ?? !multiSelect; + const shouldClosePopover = autoClose ?? !this.multiSelect; if (shouldClosePopover) { this.closePopover(); } - if (!multiSelect) { + if (!this.multiSelect) { const query = checked ? this.props.query .removeSimpleFieldClauses(field) @@ -271,7 +247,7 @@ export class FieldValueSelectionFilter extends Component< : this.props.query.removeSimpleFieldClauses(field); this.props.onChange(query); - } else if (multiSelect === 'or') { + } else if (this.multiSelect === 'or') { const query = checked ? this.props.query.addOrFieldValue(field, value, true, operator) : this.props.query.removeOrFieldValue(field, value); @@ -286,11 +262,12 @@ export class FieldValueSelectionFilter extends Component< } } - resolveMultiSelect(): MultiSelect { - const { config } = this.props; - return !isNil(config.multiSelect) - ? config.multiSelect - : defaults.config.multiSelect; + get autoSortOptions() { + return this.props.config.autoSortOptions ?? defaults.config.autoSortOptions; + } + + get multiSelect(): MultiSelect { + return this.props.config.multiSelect ?? defaults.config.multiSelect; } componentDidMount() { @@ -301,16 +278,23 @@ export class FieldValueSelectionFilter extends Component< if (this.props.query !== prevProps.query) this.loadOptions(); } + componentWillUnmount() { + clearTimeout(this.cacheTimeout); + } + render() { const { query, config } = this.props; - const multiSelect = this.resolveMultiSelect(); + + const options = this.autoSortOptions + ? this.state.options?.sorted + : this.state.options?.unsorted; const activeTop = this.isActiveField(config.field); - const activeItem = this.state.options - ? this.state.options.all.some((item) => this.isActiveField(item.field)) + const activeItem = options + ? options.some((item) => this.isActiveField(item.field)) : false; - const activeItemsCount = this.state.activeItems.length; + const { activeItemsCount } = this.state; const active = (activeTop || activeItem) && activeItemsCount > 0; const button = ( @@ -326,8 +310,8 @@ export class FieldValueSelectionFilter extends Component< ); - const items = this.state.options - ? this.state.options.shown.map((option) => { + const items = options + ? options.map((option) => { const optionField = option.field || config.field; if (optionField == null) { @@ -337,7 +321,7 @@ export class FieldValueSelectionFilter extends Component< } const clause = - multiSelect === 'or' + this.multiSelect === 'or' ? query.getOrFieldClause(optionField, option.value) : query.getSimpleFieldClause(optionField, option.value); @@ -357,8 +341,7 @@ export class FieldValueSelectionFilter extends Component< : []; const threshold = config.searchThreshold || defaults.config.searchThreshold; - const isOverSearchThreshold = - this.state.options && this.state.options.all.length >= threshold; + const isOverSearchThreshold = options && options.length >= threshold; let searchProps: ExclusiveUnion< { searchable: false }, @@ -395,11 +378,12 @@ export class FieldValueSelectionFilter extends Component< }} > > - singleSelection={!multiSelect} + ref={this.selectableClassRef} + singleSelection={!this.multiSelect} aria-label={config.name} options={items} renderOption={(option) => option.view} - isLoading={isNil(this.state.options)} + isLoading={isNil(options)} loadingMessage={config.loadingMessage} emptyMessage={config.noOptionsMessage} errorMessage={this.state.error} @@ -445,9 +429,8 @@ export class FieldValueSelectionFilter extends Component< } const { query } = this.props; - const multiSelect = this.resolveMultiSelect(); - if (multiSelect === 'or') { + if (this.multiSelect === 'or') { return query.hasOrFieldClause(field); } diff --git a/packages/eui/src/components/search_bar/search_bar.stories.tsx b/packages/eui/src/components/search_bar/search_bar.stories.tsx index cdce9bc461e..f30f79ce91c 100644 --- a/packages/eui/src/components/search_bar/search_bar.stories.tsx +++ b/packages/eui/src/components/search_bar/search_bar.stories.tsx @@ -148,6 +148,7 @@ export const Playground: Story = { ); }, 2000); }), + autoSortOptions: true, }, ], // casting to any to allow for easier teasting/QA via toggle switch diff --git a/packages/eui/src/components/search_bar/search_bar_filters.stories.tsx b/packages/eui/src/components/search_bar/search_filters.stories.tsx similarity index 98% rename from packages/eui/src/components/search_bar/search_bar_filters.stories.tsx rename to packages/eui/src/components/search_bar/search_filters.stories.tsx index e4897b9e223..3c54f26ddff 100644 --- a/packages/eui/src/components/search_bar/search_bar_filters.stories.tsx +++ b/packages/eui/src/components/search_bar/search_filters.stories.tsx @@ -88,6 +88,7 @@ export const Playground: Story = { ); }, 2000); }), + autoSortOptions: true, }, ], // setting up props for easier testing/QA