[input controls] update dropdown suggestions when filtered (#18985) (#20604)

* input controls - re-fetch options list user input

* fix jest tests

* add functional test case

* remove unneeded async and await

* use fetchAsRejectablePromise instead of fetch to avoid kill kibana session when fetching agg results

* only show options once field is selected

* clean up list control editor to only display options when field selected and only display dynamic checkbox when field is string

* do not use size when using dynamic options

* display disabled toggle when field is not string field, allow searching in middle of word

* no tooltip for disabled dyncamic options, just change help text

* fix functional test expects since search now includes more than terms that start with
This commit is contained in:
Nathan Reese 2018-07-10 07:37:35 -06:00 committed by GitHub
parent 3c14ed0918
commit a0bd039e2e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 725 additions and 160 deletions

View file

@ -1,6 +1,197 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`parentCandidates 1`] = `
exports[`renders dynamic options should display disabled dynamic options with tooltip for non-string fields 1`] = `
<div>
<IndexPatternSelect
controlIndex={0}
getIndexPattern={[Function]}
getIndexPatterns={[Function]}
indexPatternId="mockIndexPattern"
onChange={[Function]}
/>
<FieldSelect
controlIndex={0}
fieldName="numberField"
filterField={[Function]}
getIndexPattern={[Function]}
indexPatternId="mockIndexPattern"
onChange={[Function]}
/>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
helpText="Allow multiple selection"
id="multiselect-0"
key="multiselect"
>
<EuiSwitch
checked={true}
data-test-subj="listControlMultiselectInput"
label="Multiselect"
onChange={[Function]}
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
helpText="Only available for \\"string\\" fields"
id="dynamicOptions-0"
key="dynamicOptions"
>
<EuiSwitch
checked={true}
data-test-subj="listControlDynamicOptionsSwitch"
disabled={true}
label="Dynamic Options"
onChange={[Function]}
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
helpText="Number of options"
id="size-0"
key="size"
label="Size"
>
<EuiFieldNumber
compressed={false}
data-test-subj="listControlSizeInput"
fullWidth={false}
isLoading={false}
min={1}
onChange={[Function]}
value={5}
/>
</EuiFormRow>
</div>
`;
exports[`renders dynamic options should display dynamic options for string fields 1`] = `
<div>
<IndexPatternSelect
controlIndex={0}
getIndexPattern={[Function]}
getIndexPatterns={[Function]}
indexPatternId="mockIndexPattern"
onChange={[Function]}
/>
<FieldSelect
controlIndex={0}
fieldName="keywordField"
filterField={[Function]}
getIndexPattern={[Function]}
indexPatternId="mockIndexPattern"
onChange={[Function]}
/>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
helpText="Allow multiple selection"
id="multiselect-0"
key="multiselect"
>
<EuiSwitch
checked={true}
data-test-subj="listControlMultiselectInput"
label="Multiselect"
onChange={[Function]}
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
helpText="Update options in response to user input"
id="dynamicOptions-0"
key="dynamicOptions"
>
<EuiSwitch
checked={true}
data-test-subj="listControlDynamicOptionsSwitch"
disabled={false}
label="Dynamic Options"
onChange={[Function]}
/>
</EuiFormRow>
</div>
`;
exports[`renders dynamic options should display size field when dynamic options is disabled 1`] = `
<div>
<IndexPatternSelect
controlIndex={0}
getIndexPattern={[Function]}
getIndexPatterns={[Function]}
indexPatternId="mockIndexPattern"
onChange={[Function]}
/>
<FieldSelect
controlIndex={0}
fieldName="keywordField"
filterField={[Function]}
getIndexPattern={[Function]}
indexPatternId="mockIndexPattern"
onChange={[Function]}
/>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
helpText="Allow multiple selection"
id="multiselect-0"
key="multiselect"
>
<EuiSwitch
checked={true}
data-test-subj="listControlMultiselectInput"
label="Multiselect"
onChange={[Function]}
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
helpText="Update options in response to user input"
id="dynamicOptions-0"
key="dynamicOptions"
>
<EuiSwitch
checked={false}
data-test-subj="listControlDynamicOptionsSwitch"
disabled={false}
label="Dynamic Options"
onChange={[Function]}
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
helpText="Number of options"
id="size-0"
key="size"
label="Size"
>
<EuiFieldNumber
compressed={false}
data-test-subj="listControlSizeInput"
fullWidth={false}
isLoading={false}
min={1}
onChange={[Function]}
value={5}
/>
</EuiFormRow>
</div>
`;
exports[`renders should display chaining input when parents are provided 1`] = `
<div>
<IndexPatternSelect
controlIndex={0}
@ -23,6 +214,7 @@ exports[`parentCandidates 1`] = `
hasEmptyLabelSpace={false}
helpText="Options are based on the value of parent control. Disabled if parent is not set."
id="parentSelect-0"
key="parentSelect"
label="Parent control"
>
<EuiSelect
@ -53,7 +245,9 @@ exports[`parentCandidates 1`] = `
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
helpText="Allow multiple selection"
id="multiselect-0"
key="multiselect"
>
<EuiSwitch
checked={true}
@ -66,7 +260,25 @@ exports[`parentCandidates 1`] = `
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
helpText="Update options in response to user input"
id="dynamicOptions-0"
key="dynamicOptions"
>
<EuiSwitch
checked={false}
data-test-subj="listControlDynamicOptionsSwitch"
disabled={false}
label="Dynamic Options"
onChange={[Function]}
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
helpText="Number of options"
id="size-0"
key="size"
label="Size"
>
<EuiFieldNumber
@ -82,52 +294,22 @@ exports[`parentCandidates 1`] = `
</div>
`;
exports[`renders ListControlEditor 1`] = `
exports[`renders should not display any options until field is selected 1`] = `
<div>
<IndexPatternSelect
controlIndex={0}
getIndexPattern={[Function]}
getIndexPatterns={[Function]}
indexPatternId="indexPattern1"
indexPatternId="mockIndexPattern"
onChange={[Function]}
/>
<FieldSelect
controlIndex={0}
fieldName="keywordField"
fieldName=""
filterField={[Function]}
getIndexPattern={[Function]}
indexPatternId="indexPattern1"
indexPatternId="mockIndexPattern"
onChange={[Function]}
/>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
id="multiselect-0"
>
<EuiSwitch
checked={true}
data-test-subj="listControlMultiselectInput"
label="Multiselect"
onChange={[Function]}
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
id="size-0"
label="Size"
>
<EuiFieldNumber
compressed={false}
data-test-subj="listControlSizeInput"
fullWidth={false}
isLoading={false}
min={1}
onChange={[Function]}
value={10}
/>
</EuiFormRow>
</div>
`;

View file

@ -18,7 +18,7 @@
*/
import PropTypes from 'prop-types';
import React from 'react';
import React, { Component } from 'react';
import { IndexPatternSelect } from './index_pattern_select';
import { FieldSelect } from './field_select';
@ -33,87 +33,186 @@ function filterField(field) {
return field.aggregatable && ['number', 'boolean', 'date', 'ip', 'string'].includes(field.type);
}
export function ListControlEditor(props) {
const multiselectId = `multiselect-${props.controlIndex}`;
const sizeId = `size-${props.controlIndex}`;
const handleMultiselectChange = (evt) => {
props.handleCheckboxOptionChange(props.controlIndex, 'multiselect', evt);
};
const handleSizeChange = (evt) => {
props.handleNumberOptionChange(props.controlIndex, 'size', evt);
};
const handleParentChange = (evt) => {
props.handleParentChange(props.controlIndex, evt);
export class ListControlEditor extends Component {
state = {
isLoadingFieldType: true,
isStringField: false,
prevFieldName: this.props.controlParams.fieldName,
};
let parentSelect;
if (props.parentCandidates && props.parentCandidates.length > 0) {
const options = [
{ value: '', text: '' },
...props.parentCandidates,
];
parentSelect = (
<EuiFormRow
id={`parentSelect-${props.controlIndex}`}
label="Parent control"
helpText="Options are based on the value of parent control. Disabled if parent is not set."
>
<EuiSelect
options={options}
value={props.controlParams.parent}
onChange={handleParentChange}
/>
</EuiFormRow>
);
componentDidMount() {
this._isMounted = true;
this.loadIsStringField();
}
return (
<div>
componentWillUnmount() {
this._isMounted = false;
}
<IndexPatternSelect
indexPatternId={props.controlParams.indexPattern}
onChange={props.handleIndexPatternChange}
getIndexPatterns={props.getIndexPatterns}
getIndexPattern={props.getIndexPattern}
controlIndex={props.controlIndex}
/>
static getDerivedStateFromProps = (nextProps, prevState) => {
const isNewFieldName = prevState.prevFieldName !== nextProps.controlParams.fieldName;
if (!prevState.isLoadingFieldType && isNewFieldName) {
return {
isLoadingFieldType: true,
};
}
<FieldSelect
fieldName={props.controlParams.fieldName}
indexPatternId={props.controlParams.indexPattern}
filterField={filterField}
onChange={props.handleFieldNameChange}
getIndexPattern={props.getIndexPattern}
controlIndex={props.controlIndex}
/>
return null;
}
{ parentSelect }
componentDidUpdate = () => {
if (this.state.isLoadingFieldType) {
this.loadIsStringField();
}
}
loadIsStringField = async () => {
if (!this.props.controlParams.indexPattern || !this.props.controlParams.fieldName) {
this.setState({ isLoadingFieldType: false });
return;
}
let indexPattern;
try {
indexPattern = await this.props.getIndexPattern(this.props.controlParams.indexPattern);
} catch (err) {
// index pattern no longer exists
return;
}
if (!this._isMounted) {
return;
}
const field = indexPattern.fields.find((field) => {
return field.name === this.props.controlParams.fieldName;
});
if (!field) {
return;
}
this.setState({
isLoadingFieldType: false,
isStringField: field.type === 'string'
});
}
renderOptions = () => {
if (this.state.isLoadingFieldType || !this.props.controlParams.fieldName) {
return;
}
const options = [];
if (this.props.parentCandidates && this.props.parentCandidates.length > 0) {
const parentCandidatesOptions = [
{ value: '', text: '' },
...this.props.parentCandidates,
];
options.push(
<EuiFormRow
id={`parentSelect-${this.props.controlIndex}`}
label="Parent control"
helpText="Options are based on the value of parent control. Disabled if parent is not set."
key="parentSelect"
>
<EuiSelect
options={parentCandidatesOptions}
value={this.props.controlParams.parent}
onChange={(evt) => {
this.props.handleParentChange(this.props.controlIndex, evt);
}}
/>
</EuiFormRow>
);
}
options.push(
<EuiFormRow
id={multiselectId}
id={`multiselect-${this.props.controlIndex}`}
key="multiselect"
helpText="Allow multiple selection"
>
<EuiSwitch
label="Multiselect"
checked={props.controlParams.options.multiselect}
onChange={handleMultiselectChange}
checked={this.props.controlParams.options.multiselect}
onChange={(evt) => {
this.props.handleCheckboxOptionChange(this.props.controlIndex, 'multiselect', evt);
}}
data-test-subj="listControlMultiselectInput"
/>
</EuiFormRow>
);
const dynamicOptionsHelpText = this.state.isStringField
? 'Update options in response to user input'
: 'Only available for "string" fields';
options.push(
<EuiFormRow
id={sizeId}
label="Size"
id={`dynamicOptions-${this.props.controlIndex}`}
key="dynamicOptions"
helpText={dynamicOptionsHelpText}
>
<EuiFieldNumber
min={1}
value={props.controlParams.options.size}
onChange={handleSizeChange}
data-test-subj="listControlSizeInput"
<EuiSwitch
label="Dynamic Options"
checked={this.props.controlParams.options.dynamicOptions}
onChange={(evt) => {
this.props.handleCheckboxOptionChange(this.props.controlIndex, 'dynamicOptions', evt);
}}
disabled={this.state.isStringField ? false : true}
data-test-subj="listControlDynamicOptionsSwitch"
/>
</EuiFormRow>
);
</div>
);
// size is not used when dynamic options is set
if (!this.props.controlParams.options.dynamicOptions || !this.state.isStringField) {
options.push(
<EuiFormRow
id={`size-${this.props.controlIndex}`}
label="Size"
key="size"
helpText="Number of options"
>
<EuiFieldNumber
min={1}
value={this.props.controlParams.options.size}
onChange={(evt) => {
this.props.handleNumberOptionChange(this.props.controlIndex, 'size', evt);
}}
data-test-subj="listControlSizeInput"
/>
</EuiFormRow>
);
}
return options;
}
render() {
return (
<div>
<IndexPatternSelect
indexPatternId={this.props.controlParams.indexPattern}
onChange={this.props.handleIndexPatternChange}
getIndexPatterns={this.props.getIndexPatterns}
getIndexPattern={this.props.getIndexPattern}
controlIndex={this.props.controlIndex}
/>
<FieldSelect
fieldName={this.props.controlParams.fieldName}
indexPatternId={this.props.controlParams.indexPattern}
filterField={filterField}
onChange={this.props.handleFieldNameChange}
getIndexPattern={this.props.getIndexPattern}
controlIndex={this.props.controlIndex}
/>
{this.renderOptions()}
</div>
);
}
}
ListControlEditor.propTypes = {

View file

@ -37,6 +37,7 @@ const controlParams = {
options: {
type: 'terms',
multiselect: true,
dynamicOptions: false,
size: 10
}
};
@ -52,43 +53,173 @@ beforeEach(() => {
handleNumberOptionChange = sinon.spy();
});
test('renders ListControlEditor', () => {
const component = shallow(<ListControlEditor
getIndexPatterns={getIndexPatternsMock}
getIndexPattern={getIndexPatternMock}
controlIndex={0}
controlParams={controlParams}
handleFieldNameChange={handleFieldNameChange}
handleIndexPatternChange={handleIndexPatternChange}
handleCheckboxOptionChange={handleCheckboxOptionChange}
handleNumberOptionChange={handleNumberOptionChange}
handleParentChange={() => {}}
parentCandidates={[]}
/>);
expect(component).toMatchSnapshot(); // eslint-disable-line
describe('renders', () => {
test('should not display any options until field is selected', async () => {
const controlParams = {
id: '1',
indexPattern: 'mockIndexPattern',
fieldName: '',
type: 'list',
options: {
type: 'terms',
multiselect: true,
dynamicOptions: true,
size: 5,
}
};
const component = shallow(<ListControlEditor
getIndexPatterns={getIndexPatternsMock}
getIndexPattern={getIndexPatternMock}
controlIndex={0}
controlParams={controlParams}
handleFieldNameChange={handleFieldNameChange}
handleIndexPatternChange={handleIndexPatternChange}
handleCheckboxOptionChange={handleCheckboxOptionChange}
handleNumberOptionChange={handleNumberOptionChange}
handleParentChange={() => {}}
parentCandidates={[]}
/>);
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
expect(component).toMatchSnapshot();
});
test('should display chaining input when parents are provided', async () => {
const parentCandidates = [
{ value: '1', text: 'fieldA' },
{ value: '2', text: 'fieldB' }
];
const component = shallow(<ListControlEditor
getIndexPatterns={getIndexPatternsMock}
getIndexPattern={getIndexPatternMock}
controlIndex={0}
controlParams={controlParams}
handleFieldNameChange={handleFieldNameChange}
handleIndexPatternChange={handleIndexPatternChange}
handleCheckboxOptionChange={handleCheckboxOptionChange}
handleNumberOptionChange={handleNumberOptionChange}
handleParentChange={() => {}}
parentCandidates={parentCandidates}
/>);
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
expect(component).toMatchSnapshot();
});
describe('dynamic options', () => {
test('should display dynamic options for string fields', async () => {
const controlParams = {
id: '1',
indexPattern: 'mockIndexPattern',
fieldName: 'keywordField',
type: 'list',
options: {
type: 'terms',
multiselect: true,
dynamicOptions: true,
size: 5,
}
};
const component = shallow(<ListControlEditor
getIndexPatterns={getIndexPatternsMock}
getIndexPattern={getIndexPatternMock}
controlIndex={0}
controlParams={controlParams}
handleFieldNameChange={handleFieldNameChange}
handleIndexPatternChange={handleIndexPatternChange}
handleCheckboxOptionChange={handleCheckboxOptionChange}
handleNumberOptionChange={handleNumberOptionChange}
handleParentChange={() => {}}
parentCandidates={[]}
/>);
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
expect(component).toMatchSnapshot();
});
test('should display size field when dynamic options is disabled', async () => {
const controlParams = {
id: '1',
indexPattern: 'mockIndexPattern',
fieldName: 'keywordField',
type: 'list',
options: {
type: 'terms',
multiselect: true,
dynamicOptions: false,
size: 5,
}
};
const component = shallow(<ListControlEditor
getIndexPatterns={getIndexPatternsMock}
getIndexPattern={getIndexPatternMock}
controlIndex={0}
controlParams={controlParams}
handleFieldNameChange={handleFieldNameChange}
handleIndexPatternChange={handleIndexPatternChange}
handleCheckboxOptionChange={handleCheckboxOptionChange}
handleNumberOptionChange={handleNumberOptionChange}
handleParentChange={() => {}}
parentCandidates={[]}
/>);
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
expect(component).toMatchSnapshot();
});
test('should display disabled dynamic options with tooltip for non-string fields', async () => {
const controlParams = {
id: '1',
indexPattern: 'mockIndexPattern',
fieldName: 'numberField',
type: 'list',
options: {
type: 'terms',
multiselect: true,
dynamicOptions: true,
size: 5,
}
};
const component = shallow(<ListControlEditor
getIndexPatterns={getIndexPatternsMock}
getIndexPattern={getIndexPatternMock}
controlIndex={0}
controlParams={controlParams}
handleFieldNameChange={handleFieldNameChange}
handleIndexPatternChange={handleIndexPatternChange}
handleCheckboxOptionChange={handleCheckboxOptionChange}
handleNumberOptionChange={handleNumberOptionChange}
handleParentChange={() => {}}
parentCandidates={[]}
/>);
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
expect(component).toMatchSnapshot();
});
});
});
test('parentCandidates', () => {
const parentCandidates = [
{ value: '1', text: 'fieldA' },
{ value: '2', text: 'fieldB' }
];
const component = shallow(<ListControlEditor
getIndexPatterns={getIndexPatternsMock}
getIndexPattern={getIndexPatternMock}
controlIndex={0}
controlParams={controlParams}
handleFieldNameChange={handleFieldNameChange}
handleIndexPatternChange={handleIndexPatternChange}
handleCheckboxOptionChange={handleCheckboxOptionChange}
handleNumberOptionChange={handleNumberOptionChange}
handleParentChange={() => {}}
parentCandidates={parentCandidates}
/>);
expect(component).toMatchSnapshot(); // eslint-disable-line
});
test('handleCheckboxOptionChange - multiselect', () => {
test('handleCheckboxOptionChange - multiselect', async () => {
const component = mount(<ListControlEditor
getIndexPatterns={getIndexPatternsMock}
getIndexPattern={getIndexPatternMock}
@ -101,6 +232,12 @@ test('handleCheckboxOptionChange - multiselect', () => {
handleParentChange={() => {}}
parentCandidates={[]}
/>);
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
const checkbox = findTestSubject(component, 'listControlMultiselectInput');
checkbox.simulate('change', { target: { checked: true } });
sinon.assert.notCalled(handleFieldNameChange);
@ -120,7 +257,7 @@ test('handleCheckboxOptionChange - multiselect', () => {
}, 'unexpected checkbox input event'));
});
test('handleNumberOptionChange - size', () => {
test('handleNumberOptionChange - size', async () => {
const component = mount(<ListControlEditor
getIndexPatterns={getIndexPatternsMock}
getIndexPattern={getIndexPatternMock}
@ -133,6 +270,12 @@ test('handleNumberOptionChange - size', () => {
handleParentChange={() => {}}
parentCandidates={[]}
/>);
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
const input = findTestSubject(component, 'listControlSizeInput');
input.simulate('change', { target: { value: 7 } });
sinon.assert.notCalled(handleCheckboxOptionChange);

View file

@ -27,6 +27,7 @@ exports[`Apply and Cancel change btns enabled when there are changes 1`] = `
<ListControl
controlIndex={0}
disableMsg={null}
fetchOptions={[Function]}
id="mock-list-control"
label="list control"
multiselect={true}
@ -153,6 +154,7 @@ exports[`Clear btns enabled when there are values 1`] = `
<ListControl
controlIndex={0}
disableMsg={null}
fetchOptions={[Function]}
id="mock-list-control"
label="list control"
multiselect={true}
@ -279,6 +281,7 @@ exports[`Renders list control 1`] = `
<ListControl
controlIndex={0}
disableMsg={null}
fetchOptions={[Function]}
id="mock-list-control"
label="list control"
multiselect={true}

View file

@ -9,6 +9,7 @@ exports[`renders ListControl 1`] = `
<EuiComboBox
data-test-subj="listControlSelect0"
isClearable={true}
isLoading={false}
onChange={[Function]}
options={
Array [

View file

@ -63,8 +63,10 @@ export class InputControlVis extends Component {
selectedOptions={control.value}
disableMsg={control.isEnabled() ? null : control.disabledReason}
multiselect={control.options.multiselect}
dynamicOptions={control.options.dynamicOptions}
controlIndex={index}
stageFilter={this.props.stageFilter}
fetchOptions={query => { this.props.refreshControl(index, query); }}
/>
);
break;
@ -158,5 +160,6 @@ InputControlVis.propTypes = {
controls: PropTypes.array.isRequired,
updateFiltersOnChange: PropTypes.bool,
hasChanges: PropTypes.func.isRequired,
hasValues: PropTypes.func.isRequired
hasValues: PropTypes.func.isRequired,
refreshControl: PropTypes.func.isRequired,
};

View file

@ -78,6 +78,7 @@ test('Renders list control', () => {
updateFiltersOnChange={updateFiltersOnChange}
hasChanges={() => { return false; }}
hasValues={() => { return false; }}
refreshControl={() => {}}
/>);
expect(component).toMatchSnapshot(); // eslint-disable-line
});
@ -92,6 +93,7 @@ test('Renders range control', () => {
updateFiltersOnChange={updateFiltersOnChange}
hasChanges={() => { return false; }}
hasValues={() => { return false; }}
refreshControl={() => {}}
/>);
expect(component).toMatchSnapshot(); // eslint-disable-line
});
@ -106,6 +108,7 @@ test('Apply and Cancel change btns enabled when there are changes', () => {
updateFiltersOnChange={updateFiltersOnChange}
hasChanges={() => { return true; }}
hasValues={() => { return false; }}
refreshControl={() => {}}
/>);
expect(component).toMatchSnapshot(); // eslint-disable-line
});
@ -120,6 +123,7 @@ test('Clear btns enabled when there are values', () => {
updateFiltersOnChange={updateFiltersOnChange}
hasChanges={() => { return false; }}
hasValues={() => { return true; }}
refreshControl={() => {}}
/>);
expect(component).toMatchSnapshot(); // eslint-disable-line
});
@ -134,6 +138,7 @@ test('clearControls', () => {
updateFiltersOnChange={updateFiltersOnChange}
hasChanges={() => { return true; }}
hasValues={() => { return true; }}
refreshControl={() => {}}
/>);
findTestSubject(component, 'inputControlClearBtn').simulate('click');
sinon.assert.calledOnce(clearControls);
@ -152,6 +157,7 @@ test('submitFilters', () => {
updateFiltersOnChange={updateFiltersOnChange}
hasChanges={() => { return true; }}
hasValues={() => { return true; }}
refreshControl={() => {}}
/>);
findTestSubject(component, 'inputControlSubmitBtn').simulate('click');
sinon.assert.calledOnce(submitFilters);
@ -170,6 +176,7 @@ test('resetControls', () => {
updateFiltersOnChange={updateFiltersOnChange}
hasChanges={() => { return true; }}
hasValues={() => { return true; }}
refreshControl={() => {}}
/>);
findTestSubject(component, 'inputControlCancelBtn').simulate('click');
sinon.assert.calledOnce(resetControls);

View file

@ -19,6 +19,7 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import _ from 'lodash';
import { FormRow } from './form_row';
import {
@ -28,10 +29,38 @@ import {
export class ListControl extends Component {
state = {
isLoading: false
}
componentDidMount = () => {
this._isMounted = true;
}
componentWillUnmount = () => {
this._isMounted = false;
}
handleOnChange = (selectedOptions) => {
this.props.stageFilter(this.props.controlIndex, selectedOptions);
}
debouncedFetch = _.debounce(async (searchValue) => {
await this.props.fetchOptions(searchValue);
if (this._isMounted) {
this.setState({
isLoading: false,
});
}
}, 300);
onSearchChange = (searchValue) => {
this.setState({
isLoading: true,
}, this.debouncedFetch.bind(null, searchValue));
}
renderControl() {
if (this.props.disableMsg) {
return (
@ -54,6 +83,9 @@ export class ListControl extends Component {
<EuiComboBox
placeholder="Select..."
options={options}
isLoading={this.state.isLoading}
async={this.props.dynamicOptions}
onSearchChange={this.props.dynamicOptions ? this.onSearchChange : undefined}
selectedOptions={this.props.selectedOptions}
onChange={this.handleOnChange}
singleSelection={!this.props.multiselect}
@ -87,9 +119,16 @@ ListControl.propTypes = {
selectedOptions: PropTypes.arrayOf(comboBoxOptionShape).isRequired,
options: PropTypes.arrayOf(comboBoxOptionShape),
disableMsg: PropTypes.string,
multiselect: PropTypes.bool.isRequired,
multiselect: PropTypes.bool,
dynamicOptions: PropTypes.bool,
controlIndex: PropTypes.number.isRequired,
stageFilter: PropTypes.func.isRequired
stageFilter: PropTypes.func.isRequired,
fetchOptions: PropTypes.func,
};
ListControl.defaultProps = {
dynamicOptions: false,
multiselect: true,
};
ListControl.defaultProps = {

View file

@ -26,16 +26,22 @@ import {
import { PhraseFilterManager } from './filter_manager/phrase_filter_manager';
import { createSearchSource } from './create_search_source';
const termsAgg = (field, size, direction) => {
if (size < 1) {
size = 1;
}
function getEscapedQuery(query = '') {
// https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-regexp-query.html#_standard_operators
return query.replace(/[.?+*|{}[\]()"\\#@&<>~]/g, (match) => `\\${match}`);
}
const termsAgg = ({ field, size, direction, query }) => {
const terms = {
size: size,
order: {
_count: direction
}
};
if (size) {
terms.size = size < 1 ? 1 : size;
}
if (field.scripted) {
terms.script = {
inline: field.script,
@ -45,6 +51,11 @@ const termsAgg = (field, size, direction) => {
} else {
terms.field = field.name;
}
if (query) {
terms.include = `.*${getEscapedQuery(query)}.*`;
}
return {
'termsAgg': {
'terms': terms
@ -54,7 +65,7 @@ const termsAgg = (field, size, direction) => {
class ListControl extends Control {
async fetch() {
fetch = async (query) => {
const indexPattern = this.filterManager.getIndexPattern();
if (!indexPattern) {
this.disable(noIndexPatternMsg(this.controlParams.indexPattern));
@ -83,10 +94,12 @@ class ListControl extends Control {
timeout: '1s',
terminate_after: 100000
};
const aggs = termsAgg(
indexPattern.fields.byName[fieldName],
_.get(this.options, 'size', 5),
'desc');
const aggs = termsAgg({
field: indexPattern.fields.byName[fieldName],
size: this.options.dynamicOptions ? null : _.get(this.options, 'size', 5),
direction: 'desc',
query
});
const searchSource = createSearchSource(
this.kbnApi,
initialSearchSourceState,
@ -95,6 +108,7 @@ class ListControl extends Control {
this.useTimeFilter,
ancestorFilters);
this.lastQuery = query;
let resp;
try {
resp = await searchSource.fetch();
@ -102,13 +116,19 @@ class ListControl extends Control {
this.disable(`Unable to fetch terms, error: ${error.message}`);
return;
}
if (query && this.lastQuery !== query) {
// search results returned out of order - ignore results from old query
return;
}
const selectOptions = _.get(resp, 'aggregations.termsAgg.buckets', []).map((bucket) => {
return { label: this.format(bucket.key), value: bucket.key.toString() };
}).sort((a, b) => {
return a.label.toLowerCase().localeCompare(b.label.toLowerCase());
});
if(selectOptions.length === 0) {
if(selectOptions.length === 0 && !query) {
this.disable(noValuesDisableMsg(fieldName, indexPattern.title));
return;
}
@ -123,6 +143,16 @@ export async function listControlFactory(controlParams, kbnApi, useTimeFilter) {
let indexPattern;
try {
indexPattern = await kbnApi.indexPatterns.get(controlParams.indexPattern);
// dynamic options are only allowed on String fields but the setting defaults to true so it could
// be enabled for non-string fields (since UI input is hidden for non-string fields).
// If field is not string, then disable dynamic options.
const field = indexPattern.fields.find((field) => {
return field.name === controlParams.fieldName;
});
if (field && field.type !== 'string') {
controlParams.options.dynamicOptions = false;
}
} catch (err) {
// ignore not found error and return control so it can be displayed in disabled state.
}

View file

@ -75,6 +75,7 @@ export const getDefaultOptions = (type) => {
case CONTROL_TYPES.LIST:
defaultOptions.type = 'terms';
defaultOptions.multiselect = true;
defaultOptions.dynamicOptions = true;
defaultOptions.size = 5;
defaultOptions.order = 'desc';
break;

View file

@ -48,17 +48,18 @@ class VisController {
unmountComponentAtNode(this.el);
}
drawVis() {
drawVis = () => {
render(
<InputControlVis
updateFiltersOnChange={this.vis.params.updateFiltersOnChange}
controls={this.controls}
stageFilter={this.stageFilter.bind(this)}
submitFilters={this.submitFilters.bind(this)}
resetControls={this.updateControlsFromKbn.bind(this)}
clearControls={this.clearControls.bind(this)}
hasChanges={this.hasChanges.bind(this)}
hasValues={this.hasValues.bind(this)}
stageFilter={this.stageFilter}
submitFilters={this.submitFilters}
resetControls={this.updateControlsFromKbn}
clearControls={this.clearControls}
hasChanges={this.hasChanges}
hasValues={this.hasValues}
refreshControl={this.refreshControl}
/>,
this.el);
}
@ -98,7 +99,7 @@ class VisController {
return controls;
}
async stageFilter(controlIndex, newValue) {
stageFilter = async (controlIndex, newValue) => {
this.controls[controlIndex].set(newValue);
if (this.vis.params.updateFiltersOnChange) {
// submit filters on each control change
@ -110,7 +111,7 @@ class VisController {
}
}
submitFilters() {
submitFilters = () => {
// Clean up filter pills for nested controls that are now disabled because ancestors are not set
this.controls.map(async (control) => {
if (control.hasAncestors() && control.hasUnsetAncestor()) {
@ -142,14 +143,14 @@ class VisController {
this.vis.API.queryFilter.addFilters(newFilters, this.vis.params.pinFilters);
}
clearControls() {
clearControls = () => {
this.controls.forEach((control) => {
control.clear();
});
this.drawVis();
}
async updateControlsFromKbn() {
updateControlsFromKbn = async () => {
this.controls.forEach((control) => {
control.reset();
});
@ -166,7 +167,7 @@ class VisController {
return await Promise.all(fetchPromises);
}
hasChanges() {
hasChanges = () => {
return this.controls.map((control) => {
return control.hasChanged();
})
@ -175,7 +176,7 @@ class VisController {
});
}
hasValues() {
hasValues = () => {
return this.controls.map((control) => {
return control.hasValue();
})
@ -183,6 +184,11 @@ class VisController {
return a || b;
});
}
refreshControl = async (controlIndex, query) => {
await this.controls[controlIndex].fetch(query);
this.drawVis();
}
}
export { VisController };

View file

@ -182,6 +182,49 @@ export default function ({ getService, getPageObjects }) {
});
});
describe('dynamic options', () => {
beforeEach(async () => {
await PageObjects.common.navigateToUrl('visualize', 'new');
await PageObjects.visualize.clickInputControlVis();
await PageObjects.visualize.clickVisEditorTab('controls');
await PageObjects.visualize.addInputControl();
await PageObjects.visualize.setComboBox('indexPatternSelect-0', 'logstash');
await PageObjects.common.sleep(1000); // give time for index-pattern to be fetched
await PageObjects.visualize.setComboBox('fieldSelect-0', 'geo.src');
await PageObjects.visualize.clickGo();
await PageObjects.header.waitUntilLoadingHasFinished();
});
it('should fetch new options when string field is filtered', async () => {
const initialOptions = await PageObjects.visualize.getComboBoxOptions('listControlSelect0');
expect(initialOptions.trim().split('\n').join()).to.equal('BD,BR,CN,ID,IN,JP,NG,PK,RU,US');
await PageObjects.visualize.filterComboBoxOptions('listControlSelect0', 'R');
await PageObjects.header.waitUntilLoadingHasFinished();
const updatedOptions = await PageObjects.visualize.getComboBoxOptions('listControlSelect0');
expect(updatedOptions.trim().split('\n').join()).to.equal('AR,BR,FR,GR,IR,KR,RO,RU,RW,TR');
});
it('should not fetch new options when non-string is filtered', async () => {
await PageObjects.visualize.setComboBox('fieldSelect-0', 'clientip');
await PageObjects.visualize.clickGo();
await PageObjects.header.waitUntilLoadingHasFinished();
const initialOptions = await PageObjects.visualize.getComboBoxOptions('listControlSelect0');
expect(initialOptions.trim().split('\n').join()).to.equal(
'135.206.117.161,177.194.175.66,18.55.141.62,243.158.217.196,32.146.206.24');
await PageObjects.visualize.filterComboBoxOptions('listControlSelect0', '17');
await PageObjects.header.waitUntilLoadingHasFinished();
const updatedOptions = await PageObjects.visualize.getComboBoxOptions('listControlSelect0');
expect(updatedOptions.trim().split('\n').join()).to.equal('135.206.117.161,177.194.175.66,243.158.217.196');
});
});
describe('chained controls', () => {
before(async () => {
@ -206,7 +249,7 @@ export default function ({ getService, getPageObjects }) {
it('should disable child control when parent control is not set', async () => {
const parentControlMenu = await PageObjects.visualize.getComboBoxOptions('listControlSelect0');
expect(parentControlMenu.trim().split('\n').join()).to.equal('BR,CN,ID,IN,US');
expect(parentControlMenu.trim().split('\n').join()).to.equal('BD,BR,CN,ID,IN,JP,NG,PK,RU,US');
const childControlInput = await find.byCssSelector('[data-test-subj="inputControl1"] input');
const isDisabled = await childControlInput.getProperty('disabled');

View file

@ -246,6 +246,14 @@ export function VisualizePageProvider({ getService, getPageObjects }) {
await this.closeComboBoxOptionsList(element);
}
async filterComboBoxOptions(comboBoxSelector, value) {
const comboBox = await testSubjects.find(comboBoxSelector);
const input = await comboBox.findByTagName('input');
await input.clearValue();
await input.type(value);
await this.closeComboBoxOptionsList(comboBox);
}
async getComboBoxOptions(comboBoxSelector) {
await testSubjects.click(comboBoxSelector);
const menu = await retry.try(