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