Skip to content

Commit

Permalink
Support async loading of options and creation of custom options.
Browse files Browse the repository at this point in the history
  • Loading branch information
cjcenizal committed Mar 27, 2018
1 parent 0240cfa commit 3b118f3
Show file tree
Hide file tree
Showing 6 changed files with 210 additions and 35 deletions.
118 changes: 118 additions & 0 deletions src-docs/src/views/combo_box/async.js
Original file line number Diff line number Diff line change
@@ -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 (
<EuiComboBox
async
options={this.options}
selectedOptions={selectedOptions}
isLoading={isLoading}
onChange={this.onChange}
onSearchChange={this.onSearchChange}
onCreateOption={this.onCreateOption}
/>
);
}
}
21 changes: 21 additions & 0 deletions src-docs/src/views/combo_box/combo_box_example.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [{
Expand Down Expand Up @@ -75,5 +79,22 @@ export const ComboBoxExample = {
),
props: { EuiComboBox },
demo: <DisallowCustomOptions />,
}, {
title: 'Async',
source: [{
type: GuideSectionTypes.JS,
code: asyncSource,
}, {
type: GuideSectionTypes.HTML,
code: asyncHtml,
}],
text: (
<p>
Use the <EuiCode>onSearchChange</EuiCode> code to handle searches asynchronously. Use the
<EuiCode>isLoading</EuiCode> prop to let the user know that something async is happening.
</p>
),
props: { EuiComboBox },
demo: <Async />,
}],
};
38 changes: 28 additions & 10 deletions src/components/combo_box/combo_box.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -137,6 +145,10 @@ export class EuiComboBox extends Component {
}
};

clearSearchValue = () => {
this.onSearchChange('');
};

removeLastOption = () => {
if (this.hasActiveOption()) {
return;
Expand Down Expand Up @@ -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();
}
};

Expand All @@ -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;
};

Expand Down Expand Up @@ -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();
};

Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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;

Expand All @@ -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;

Expand Down Expand Up @@ -386,6 +403,7 @@ export class EuiComboBox extends Component {
/>

<EuiComboBoxOptionsList
isLoading={isLoading}
options={options}
selectedOptions={selectedOptions}
onCreateOption={onCreateOption}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const EuiComboBoxInput = ({
style={{ fontSize: 14 }}
className="euiComboBox__input"
onFocus={onFocus}
onChange={onChange}
onChange={(e) => onChange(e.target.value)}
value={value}
ref={autoSizeInputRef}
inputRef={inputRef}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,30 +26,39 @@ export const EuiComboBoxOptionsList = ({
}) => {
let emptyStateContent;

if (!options.length) {
emptyStateContent = <p>There aren&rsquo;t any options available</p>;
} else if (areAllOptionsSelected) {
emptyStateContent = <p>You&rsquo;ve selected all available options</p>;
} else if (matchingOptions.length === 0) {
if (searchValue) {
if (onCreateOption) {
const selectedOptionForValue = getSelectedOptionForSearchValue(searchValue, selectedOptions);
if (selectedOptionForValue) {
// Disallow duplicate custom options.
emptyStateContent = (
<p><strong>{selectedOptionForValue.value}</strong> has already been added</p>
);
} else {
emptyStateContent = (
<p>Hit <EuiCode>ENTER</EuiCode> to add <strong>{searchValue}</strong> as a custom option</p>
);
}
if (isLoading) {
emptyStateContent = (
<EuiFlexGroup gutterSize="s" justifyContent="center">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="m" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
Loading options
</EuiFlexItem>
</EuiFlexGroup>
);
} else if (searchValue && matchingOptions.length === 0) {
if (onCreateOption) {
const selectedOptionForValue = getSelectedOptionForSearchValue(searchValue, selectedOptions);
if (selectedOptionForValue) {
// Disallow duplicate custom options.
emptyStateContent = (
<p><strong>{selectedOptionForValue.value}</strong> has already been added</p>
);
} else {
emptyStateContent = (
<p><strong>{searchValue}</strong> doesn&rsquo;t match any options</p>
<p>Hit <EuiCode>ENTER</EuiCode> to add <strong>{searchValue}</strong> as a custom option</p>
);
}
} else {
emptyStateContent = (
<p><strong>{searchValue}</strong> doesn&rsquo;t match any options</p>
);
}
} else if (!options.length) {
emptyStateContent = <p>There aren&rsquo;t any options available</p>;
} else if (areAllOptionsSelected) {
emptyStateContent = <p>You&rsquo;ve selected all available options</p>;
}

const emptyState = emptyStateContent ? (
Expand Down Expand Up @@ -107,6 +119,7 @@ export const EuiComboBoxOptionsList = ({

EuiComboBoxOptionsList.propTypes = {
options: PropTypes.array,
isLoading: PropTypes.bool,
selectedOptions: PropTypes.array,
onCreateOption: PropTypes.func,
searchValue: PropTypes.string,
Expand Down
Loading

0 comments on commit 3b118f3

Please sign in to comment.