From 3b118f3c72fc63a65e16898a98aeb013ae51e815 Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Tue, 27 Mar 2018 16:37:25 -0700 Subject: [PATCH] Support async loading of options and creation of custom options. --- src-docs/src/views/combo_box/async.js | 118 ++++++++++++++++++ .../src/views/combo_box/combo_box_example.js | 21 ++++ src/components/combo_box/combo_box.js | 38 ++++-- .../combo_box_input/combo_box_input.js | 2 +- .../combo_box_options_list.js | 51 +++++--- src/components/combo_box/matching_options.js | 15 ++- 6 files changed, 210 insertions(+), 35 deletions(-) create mode 100644 src-docs/src/views/combo_box/async.js diff --git a/src-docs/src/views/combo_box/async.js b/src-docs/src/views/combo_box/async.js new file mode 100644 index 000000000000..25cfa2ffc85b --- /dev/null +++ b/src-docs/src/views/combo_box/async.js @@ -0,0 +1,118 @@ +import React, { Component } from 'react'; + +import { + EuiComboBox, +} from '../../../../src/components'; + +const options = [{ + value: 'titan', + label: 'Titan', + 'data-test-subj': 'titanOption', +}, { + value: 'enceladus', + label: 'Enceladus', +}, { + value: 'mimas', + label: 'Mimas', +}, { + value: 'dione', + label: 'Dione', +}, { + value: 'iapetus', + label: 'Iapetus', +}, { + value: 'phoebe', + label: 'Phoebe', +}, { + value: 'rhea', + label: 'Rhea', +}, { + value: 'pandora', + label: 'Pandora is one of Saturn\'s moons, named for a Titaness of Greek mythology', +}, { + value: 'tethys', + label: 'Tethys', +}, { + value: 'hyperion', + label: 'Hyperion', +}]; + +export default class extends Component { + constructor(props) { + super(props); + + this.options = options.slice(); + + this.state = { + isLoading: false, + isPopoverOpen: false, + selectedOptions: [this.options[2], this.options[4]], + }; + } + + onChange = (selectedOptions) => { + this.setState({ + selectedOptions, + }); + }; + + onSearchChange = (searchValue) => { + this.options = []; + + this.setState({ + isLoading: true, + }); + + clearTimeout(this.searchTimeout); + + this.searchTimeout = setTimeout(() => { + this.options = options.filter(option => option.label.toLowerCase().includes(searchValue.toLowerCase())); + this.setState({ + isLoading: false, + }); + }, 1200); + } + + onCreateOption =(searchValue, flattenedOptions) => { + const normalizedSearchValue = searchValue.trim().toLowerCase(); + + if (!normalizedSearchValue) { + return; + } + + const newOption = { + value: searchValue, + label: searchValue, + }; + + // Create the option if it doesn't exist. + if (flattenedOptions.findIndex(option => + option.value.trim().toLowerCase() === normalizedSearchValue + ) === -1) { + // Simulate creating this option on the server. + options.push(newOption); + this.options.push(newOption); + } + + // Select the option. + this.setState(prevState => ({ + selectedOptions: prevState.selectedOptions.concat(newOption), + })); + }; + + render() { + const { selectedOptions, isLoading } = this.state; + + return ( + + ); + } +} diff --git a/src-docs/src/views/combo_box/combo_box_example.js b/src-docs/src/views/combo_box/combo_box_example.js index ac0f0b013d8a..3a143ac804a3 100644 --- a/src-docs/src/views/combo_box/combo_box_example.js +++ b/src-docs/src/views/combo_box/combo_box_example.js @@ -23,6 +23,10 @@ import DisallowCustomOptions from './disallow_custom_options'; const disallowCustomOptionsSource = require('!!raw-loader!./disallow_custom_options'); const disallowCustomOptionsHtml = renderToHtml(DisallowCustomOptions); +import Async from './async'; +const asyncSource = require('!!raw-loader!./async'); +const asyncHtml = renderToHtml(Async); + export const ComboBoxExample = { title: 'Combo Box', sections: [{ @@ -75,5 +79,22 @@ export const ComboBoxExample = { ), props: { EuiComboBox }, demo: , + }, { + title: 'Async', + source: [{ + type: GuideSectionTypes.JS, + code: asyncSource, + }, { + type: GuideSectionTypes.HTML, + code: asyncHtml, + }], + text: ( +

+ Use the onSearchChange code to handle searches asynchronously. Use the + isLoading prop to let the user know that something async is happening. +

+ ), + props: { EuiComboBox }, + demo: , }], }; diff --git a/src/components/combo_box/combo_box.js b/src/components/combo_box/combo_box.js index 06cfbf4df104..ab38d6953a58 100644 --- a/src/components/combo_box/combo_box.js +++ b/src/components/combo_box/combo_box.js @@ -24,6 +24,8 @@ import { export class EuiComboBox extends Component { static propTypes = { className: PropTypes.string, + isLoading: PropTypes.bool, + async: PropTypes.bool, options: PropTypes.array, selectedOptions: PropTypes.array, onChange: PropTypes.func.isRequired, @@ -41,7 +43,7 @@ export class EuiComboBox extends Component { const initialSearchValue = ''; const { options, selectedOptions } = props; - const { matchingOptions, optionToGroupMap } = getMatchingOptions(options, selectedOptions, initialSearchValue); + const { matchingOptions, optionToGroupMap } = this.getMatchingOptions(options, selectedOptions, initialSearchValue); this.state = { searchValue: initialSearchValue, @@ -59,6 +61,12 @@ export class EuiComboBox extends Component { this.searchInput = undefined; } + getMatchingOptions = (options, selectedOptions, searchValue) => { + // Assume the consumer has already filtered the options against the search value. + const isPreFiltered = this.props.async; + return getMatchingOptions(options, selectedOptions, searchValue, isPreFiltered); + }; + openList = () => { this.setState({ isListOpen: true, @@ -137,6 +145,10 @@ export class EuiComboBox extends Component { } }; + clearSearchValue = () => { + this.onSearchChange(''); + }; + removeLastOption = () => { if (this.hasActiveOption()) { return; @@ -173,7 +185,7 @@ export class EuiComboBox extends Component { // Add new custom pill if this is custom input, even if it partially matches an option.. if (!this.hasActiveOption() || this.doesSearchMatchOnlyOption()) { this.props.onCreateOption(this.state.searchValue, flattenOptionGroups(this.props.options)); - this.setState({ searchValue: '' }); + this.clearSearchValue(); } }; @@ -186,7 +198,11 @@ export class EuiComboBox extends Component { }; areAllOptionsSelected = () => { - const { options, selectedOptions } = this.props; + const { options, selectedOptions, async } = this.props; + // Assume if this is async then there could be infinite options. + if (async) { + return false; + } return flattenOptionGroups(options).length === selectedOptions.length; }; @@ -247,7 +263,7 @@ export class EuiComboBox extends Component { const { onChange, selectedOptions } = this.props; onChange(selectedOptions.concat(addedOption)); this.clearActiveOption(); - this.setState({ searchValue: '' }); + this.clearSearchValue(); this.searchInput.focus(); }; @@ -285,12 +301,11 @@ export class EuiComboBox extends Component { }); }; - onSearchChange = (e) => { + onSearchChange = (searchValue) => { if (this.props.onSearchChange) { - this.props.onSearchChange(); + this.props.onSearchChange(searchValue); } - - this.setState({ searchValue: e.target.value }) + this.setState({ searchValue }); }; comboBoxRef = node => { @@ -335,7 +350,7 @@ export class EuiComboBox extends Component { // Calculate and cache the options which match the searchValue, because we use this information // in multiple places and it would be expensive to calculate repeatedly. - const { matchingOptions, optionToGroupMap } = getMatchingOptions(options, selectedOptions, nextState.searchValue); + const { matchingOptions, optionToGroupMap } = this.getMatchingOptions(options, selectedOptions, nextState.searchValue); this.matchingOptions = matchingOptions; this.optionToGroupMap = optionToGroupMap; @@ -351,11 +366,13 @@ export class EuiComboBox extends Component { render() { const { className, + isLoading, options, selectedOptions, - onChange, // eslint-disable-line no-unused-vars onCreateOption, + onChange, // eslint-disable-line no-unused-vars onSearchChange, // eslint-disable-line no-unused-vars + async, // eslint-disable-line no-unused-vars ...rest } = this.props; @@ -386,6 +403,7 @@ export class EuiComboBox extends Component { /> onChange(e.target.value)} value={value} ref={autoSizeInputRef} inputRef={inputRef} diff --git a/src/components/combo_box/combo_box_options_list/combo_box_options_list.js b/src/components/combo_box/combo_box_options_list/combo_box_options_list.js index 46f001b5974f..be06bdba3392 100644 --- a/src/components/combo_box/combo_box_options_list/combo_box_options_list.js +++ b/src/components/combo_box/combo_box_options_list/combo_box_options_list.js @@ -2,14 +2,17 @@ import React from 'react'; import PropTypes from 'prop-types'; import { EuiCode } from '../../code'; +import { EuiFlexGroup, EuiFlexItem } from '../../flex'; import { EuiHighlight } from '../../highlight'; import { EuiPanel } from '../../panel'; import { EuiText } from '../../text'; +import { EuiLoadingSpinner } from '../../loading'; import { EuiComboBoxOption } from './combo_box_option'; import { EuiComboBoxTitle } from './combo_box_title'; export const EuiComboBoxOptionsList = ({ options, + isLoading, selectedOptions, onCreateOption, searchValue, @@ -23,30 +26,39 @@ export const EuiComboBoxOptionsList = ({ }) => { let emptyStateContent; - if (!options.length) { - emptyStateContent =

There aren’t any options available

; - } else if (areAllOptionsSelected) { - emptyStateContent =

You’ve selected all available options

; - } else if (matchingOptions.length === 0) { - if (searchValue) { - if (onCreateOption) { - const selectedOptionForValue = getSelectedOptionForSearchValue(searchValue, selectedOptions); - if (selectedOptionForValue) { - // Disallow duplicate custom options. - emptyStateContent = ( -

{selectedOptionForValue.value} has already been added

- ); - } else { - emptyStateContent = ( -

Hit ENTER to add {searchValue} as a custom option

- ); - } + if (isLoading) { + emptyStateContent = ( + + + + + + Loading options + + + ); + } else if (searchValue && matchingOptions.length === 0) { + if (onCreateOption) { + const selectedOptionForValue = getSelectedOptionForSearchValue(searchValue, selectedOptions); + if (selectedOptionForValue) { + // Disallow duplicate custom options. + emptyStateContent = ( +

{selectedOptionForValue.value} has already been added

+ ); } else { emptyStateContent = ( -

{searchValue} doesn’t match any options

+

Hit ENTER to add {searchValue} as a custom option

); } + } else { + emptyStateContent = ( +

{searchValue} doesn’t match any options

+ ); } + } else if (!options.length) { + emptyStateContent =

There aren’t any options available

; + } else if (areAllOptionsSelected) { + emptyStateContent =

You’ve selected all available options

; } const emptyState = emptyStateContent ? ( @@ -107,6 +119,7 @@ export const EuiComboBoxOptionsList = ({ EuiComboBoxOptionsList.propTypes = { options: PropTypes.array, + isLoading: PropTypes.bool, selectedOptions: PropTypes.array, onCreateOption: PropTypes.func, searchValue: PropTypes.string, diff --git a/src/components/combo_box/matching_options.js b/src/components/combo_box/matching_options.js index 38e39cf32ed3..86bc46677dea 100644 --- a/src/components/combo_box/matching_options.js +++ b/src/components/combo_box/matching_options.js @@ -14,13 +14,19 @@ export const getSelectedOptionForSearchValue = (searchValue, selectedOptions) => return selectedOptions.find(option => option.label.toLowerCase() === normalizedSearchValue); }; -const collectMatchingOption = (accumulator, option, selectedOptions, normalizedSearchValue) => { +const collectMatchingOption = (accumulator, option, selectedOptions, normalizedSearchValue, isPreFiltered) => { // Only show options which haven't yet been selected. const selectedOption = getSelectedOptionForSearchValue(option.label, selectedOptions); if (selectedOption) { return false; } + // If the options have already been prefiltered then we can skip filtering against the search value. + if (isPreFiltered) { + accumulator.push(option); + return; + } + if (!normalizedSearchValue) { accumulator.push(option); return; @@ -32,7 +38,7 @@ const collectMatchingOption = (accumulator, option, selectedOptions, normalizedS } }; -export const getMatchingOptions = (options, selectedOptions, searchValue) => { +export const getMatchingOptions = (options, selectedOptions, searchValue, isPreFiltered) => { const normalizedSearchValue = searchValue.trim().toLowerCase(); const optionToGroupMap = new Map(); const matchingOptions = []; @@ -41,12 +47,11 @@ export const getMatchingOptions = (options, selectedOptions, searchValue) => { if (option.options) { option.options.forEach(groupOption => { optionToGroupMap.set(groupOption, option) - collectMatchingOption(matchingOptions, groupOption, selectedOptions, normalizedSearchValue); + collectMatchingOption(matchingOptions, groupOption, selectedOptions, normalizedSearchValue, isPreFiltered); }) } else { - collectMatchingOption(matchingOptions, option, selectedOptions, normalizedSearchValue); + collectMatchingOption(matchingOptions, option, selectedOptions, normalizedSearchValue, isPreFiltered); } }); - return { optionToGroupMap, matchingOptions }; };