[Input controls] Replace react-select with EuiComboBox (#17452)

* replace react-select with EuiComboBox

* remove constructor for ListControl component

* get working with portal version

* remove overflow visible from input_control_vis since its no longer needed

* convert index pattern select to EuiComboBox

* replace react-select with EuiComboBox for field select

* group fields by type

* remove esvm

* remove on-foxus box around input cursor

* fix jest tests

* remove broken jest test

* fix functional tests

* review changes

* remove componentWillMount from field_select

* update snapshot changed from rebasing and getting new EUI version

* use combo box clear and close buttons for clearing and closing

* jsdoc syntax fix
This commit is contained in:
Nathan Reese 2018-05-08 13:48:12 -06:00 committed by GitHub
parent 147d208e2d
commit e3841ea7ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 474 additions and 472 deletions

View file

@ -4,17 +4,18 @@ exports[`parentCandidates 1`] = `
<div>
<IndexPatternSelect
controlIndex={0}
getIndexPattern={[Function]}
getIndexPatterns={[Function]}
indexPatternId="indexPattern1"
onChange={[Function]}
value="indexPattern1"
/>
<FieldSelect
controlIndex={0}
fieldName="keywordField"
filterField={[Function]}
getIndexPattern={[Function]}
indexPatternId="indexPattern1"
onChange={[Function]}
value="keywordField"
/>
<EuiFormRow
describedByIds={Array []}
@ -83,17 +84,18 @@ exports[`renders ListControlEditor 1`] = `
<div>
<IndexPatternSelect
controlIndex={0}
getIndexPattern={[Function]}
getIndexPatterns={[Function]}
indexPatternId="indexPattern1"
onChange={[Function]}
value="indexPattern1"
/>
<FieldSelect
controlIndex={0}
fieldName="keywordField"
filterField={[Function]}
getIndexPattern={[Function]}
indexPatternId="indexPattern1"
onChange={[Function]}
value="keywordField"
/>
<EuiFormRow
describedByIds={Array []}

View file

@ -4,17 +4,18 @@ exports[`renders RangeControlEditor 1`] = `
<div>
<IndexPatternSelect
controlIndex={0}
getIndexPattern={[Function]}
getIndexPatterns={[Function]}
indexPatternId="indexPattern1"
onChange={[Function]}
value="indexPattern1"
/>
<FieldSelect
controlIndex={0}
fieldName="numberField"
filterField={[Function]}
getIndexPattern={[Function]}
indexPatternId="indexPattern1"
onChange={[Function]}
value="numberField"
/>
<EuiFormRow
describedByIds={Array []}

View file

@ -0,0 +1,11 @@
export const getIndexPatternMock = () => {
return Promise.resolve({
id: 'mockIndexPattern',
title: 'mockIndexPattern',
fields: [
{ name: 'keywordField', type: 'string', aggregatable: true },
{ name: 'textField', type: 'string', aggregatable: false },
{ name: 'numberField', type: 'number', aggregatable: true }
]
});
};

View file

@ -0,0 +1,16 @@
export const getIndexPatternsMock = () => {
return Promise.resolve([
{
id: 'indexPattern1',
attributes: {
title: 'indexPattern1'
}
},
{
id: 'indexPattern2',
attributes: {
title: 'indexPattern2'
}
}
]);
};

View file

@ -48,16 +48,16 @@ export class ControlsTab extends Component {
this.setVisParam('controls', setControl(this.props.scope.vis.params.controls, controlIndex, updatedControl));
}
handleIndexPatternChange = (controlIndex, evt) => {
handleIndexPatternChange = (controlIndex, indexPatternId) => {
const updatedControl = this.props.scope.vis.params.controls[controlIndex];
updatedControl.indexPattern = evt.value;
updatedControl.indexPattern = indexPatternId;
updatedControl.fieldName = '';
this.setVisParam('controls', setControl(this.props.scope.vis.params.controls, controlIndex, updatedControl));
}
handleFieldNameChange = (controlIndex, evt) => {
handleFieldNameChange = (controlIndex, fieldName) => {
const updatedControl = this.props.scope.vis.params.controls[controlIndex];
updatedControl.fieldName = evt.value;
updatedControl.fieldName = fieldName;
this.setVisParam('controls', setControl(this.props.scope.vis.params.controls, controlIndex, updatedControl));
}

View file

@ -2,6 +2,7 @@ import React from 'react';
import sinon from 'sinon';
import { mount, shallow } from 'enzyme';
import { findTestSubject } from '@elastic/eui/lib/test';
import { getIndexPatternMock } from './__tests__/get_index_pattern_mock';
import {
ControlsTab,
} from './controls_tab';
@ -21,14 +22,7 @@ const savedObjectsClientMock = {
}
};
const indexPatternsMock = {
get: () => {
return Promise.resolve({
fields: [
{ name: 'keywordField', type: 'string', aggregatable: true },
{ name: 'numberField', type: 'number', aggregatable: true }
]
});
}
get: getIndexPatternMock
};
const scopeMock = {
vis: {

View file

@ -1,60 +1,102 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Select from 'react-select';
import {
EuiFormRow,
EuiComboBox,
} from '@elastic/eui';
export class FieldSelect extends Component {
constructor(props) {
super(props);
// not storing activeIndexPatternId in react state
// 1) does not effect rendering
// 2) requires synchronous modification to avoid race condition
this.activeIndexPatternId = props.indexPatternId;
this._hasUnmounted = false;
this.state = {
fields: []
isLoading: false,
fields: [],
indexPatternId: props.indexPatternId,
};
this.filterField = _.get(props, 'filterField', () => { return true; });
this.loadFields(props.indexPatternId);
}
componentWillUnmount() {
this._hasUnmounted = true;
}
componentDidMount() {
this.loadFields(this.state.indexPatternId);
}
componentWillReceiveProps(nextProps) {
if (this.props.indexPatternId !== nextProps.indexPatternId) {
this.activeIndexPatternId = nextProps.indexPatternId;
this.setState({ fields: [] });
this.loadFields(nextProps.indexPatternId);
}
}
async loadFields(indexPatternId) {
loadFields = (indexPatternId) => {
this.setState({
isLoading: true,
fields: [],
indexPatternId
}, this.debouncedLoad.bind(null, indexPatternId));
}
debouncedLoad = _.debounce(async (indexPatternId) => {
if (!indexPatternId || indexPatternId.length === 0) {
return;
}
const indexPattern = await this.props.getIndexPattern(indexPatternId);
// props.indexPatternId may be updated before getIndexPattern returns
// ignore response when fetched index pattern does not match active index pattern
if (indexPattern.id !== this.activeIndexPatternId) {
if (this._hasUnmounted) {
return;
}
const fields = indexPattern.fields
// props.indexPatternId may be updated before getIndexPattern returns
// ignore response when fetched index pattern does not match active index pattern
if (indexPattern.id !== this.state.indexPatternId) {
return;
}
const fieldsByTypeMap = new Map();
const fields = [];
indexPattern.fields
.filter(this.filterField)
.sort((a, b) => {
if (a.name < b.name) return -1;
if (a.name > b.name) return 1;
return 0;
})
.map(function (field) {
return { label: field.name, value: field.name };
.forEach(field => {
if (fieldsByTypeMap.has(field.type)) {
const fieldsList = fieldsByTypeMap.get(field.type);
fieldsList.push(field.name);
fieldsByTypeMap.set(field.type, fieldsList);
} else {
fieldsByTypeMap.set(field.type, [field.name]);
}
});
this.setState({ fields: fields });
fieldsByTypeMap.forEach((fieldsList, fieldType) => {
fields.push({
label: fieldType,
options: fieldsList.sort().map(fieldName => {
return { value: fieldName, label: fieldName };
})
});
});
fields.sort((a, b) => {
if (a.label < b.label) return -1;
if (a.label > b.label) return 1;
return 0;
});
this.setState({
isLoading: false,
fields: fields
});
}, 300);
onChange = (selectedOptions) => {
this.props.onChange(_.get(selectedOptions, '0.value'));
}
render() {
@ -63,19 +105,25 @@ export class FieldSelect extends Component {
}
const selectId = `fieldSelect-${this.props.controlIndex}`;
const selectedOptions = [];
if (this.props.fieldName) {
selectedOptions.push({ value: this.props.fieldName, label: this.props.fieldName });
}
return (
<EuiFormRow
id={selectId}
label="Field"
>
<Select
className="field-react-select"
<EuiComboBox
placeholder="Select field..."
value={this.props.value}
singleSelection={true}
isLoading={this.state.isLoading}
options={this.state.fields}
onChange={this.props.onChange}
resetValue={''}
inputProps={{ id: selectId }}
selectedOptions={selectedOptions}
onChange={this.onChange}
data-test-subj={selectId}
/>
</EuiFormRow>
);
@ -86,7 +134,7 @@ FieldSelect.propTypes = {
getIndexPattern: PropTypes.func.isRequired,
indexPatternId: PropTypes.string,
onChange: PropTypes.func.isRequired,
value: PropTypes.string,
fieldName: PropTypes.string,
filterField: PropTypes.func,
controlIndex: PropTypes.number.isRequired,
};

View file

@ -1,45 +1,123 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Select from 'react-select';
import {
EuiFormRow,
EuiComboBox,
} from '@elastic/eui';
export class IndexPatternSelect extends Component {
constructor(props) {
super(props);
this.loadOptions = this.loadOptions.bind(this);
this.state = {
isLoading: false,
options: [],
selectedIndexPattern: undefined,
};
}
loadOptions(input, callback) {
this.props.getIndexPatterns(input).then((indexPatternSavedObjects) => {
componentWillMount() {
this._isMounted = true;
}
componentWillUnmount() {
this._isMounted = false;
this.debouncedFetch.cancel();
}
componentDidMount() {
this.fetchOptions();
this.fetchSelectedIndexPattern(this.props.indexPatternId);
}
componentWillReceiveProps(nextProps) {
if (nextProps.indexPatternId !== this.props.indexPatternId) {
this.fetchSelectedIndexPattern(nextProps.indexPatternId);
}
}
fetchSelectedIndexPattern = async (indexPatternId) => {
if (!indexPatternId) {
this.setState({
selectedIndexPattern: undefined
});
return;
}
const indexPattern = await this.props.getIndexPattern(indexPatternId);
if (!this._isMounted) {
return;
}
if (!indexPattern) {
return;
}
this.setState({
selectedIndexPattern: {
value: indexPattern.id,
label: indexPattern.title,
}
});
}
debouncedFetch = _.debounce(async (searchValue) => {
const indexPatternSavedObjects = await this.props.getIndexPatterns(searchValue);
if (!this._isMounted) {
return;
}
// We need this check to handle the case where search results come back in a different
// order than they were sent out. Only load results for the most recent search.
if (searchValue === this.state.searchValue) {
const options = indexPatternSavedObjects.map((indexPatternSavedObject) => {
return {
label: indexPatternSavedObject.attributes.title,
value: indexPatternSavedObject.id
};
});
callback(null, { options: options });
});
this.setState({
isLoading: false,
options,
});
}
}, 300);
fetchOptions = (searchValue = '') => {
this.setState({
isLoading: true,
searchValue
}, this.debouncedFetch.bind(null, searchValue));
}
onChange = (selectedOptions) => {
this.props.onChange(_.get(selectedOptions, '0.value'));
}
render() {
const selectId = `indexPatternSelect-${this.props.controlIndex}`;
const selectedOptions = [];
if (this.state.selectedIndexPattern) {
selectedOptions.push(this.state.selectedIndexPattern);
}
return (
<EuiFormRow
id={selectId}
label="Index Pattern"
>
<Select.Async
className="index-pattern-react-select"
<EuiComboBox
placeholder="Select index pattern..."
value={this.props.value}
loadOptions={this.loadOptions}
onChange={this.props.onChange}
resetValue={''}
inputProps={{ id: selectId }}
singleSelection={true}
isLoading={this.state.isLoading}
onSearchChange={this.fetchOptions}
options={this.state.options}
selectedOptions={selectedOptions}
onChange={this.onChange}
data-test-subj={selectId}
/>
</EuiFormRow>
);
@ -48,7 +126,8 @@ export class IndexPatternSelect extends Component {
IndexPatternSelect.propTypes = {
getIndexPatterns: PropTypes.func.isRequired,
getIndexPattern: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
value: PropTypes.string,
indexPatternId: PropTypes.string,
controlIndex: PropTypes.number.isRequired,
};

View file

@ -52,14 +52,15 @@ export function ListControlEditor(props) {
<div>
<IndexPatternSelect
value={props.controlParams.indexPattern}
indexPatternId={props.controlParams.indexPattern}
onChange={props.handleIndexPatternChange}
getIndexPatterns={props.getIndexPatterns}
getIndexPattern={props.getIndexPattern}
controlIndex={props.controlIndex}
/>
<FieldSelect
value={props.controlParams.fieldName}
fieldName={props.controlParams.fieldName}
indexPatternId={props.controlParams.indexPattern}
filterField={filterField}
onChange={props.handleFieldNameChange}

View file

@ -2,36 +2,13 @@ import React from 'react';
import sinon from 'sinon';
import { mount, shallow } from 'enzyme';
import { findTestSubject } from '@elastic/eui/lib/test';
import { getIndexPatternMock } from './__tests__/get_index_pattern_mock';
import { getIndexPatternsMock } from './__tests__/get_index_patterns_mock';
import {
ListControlEditor,
} from './list_control_editor';
const getIndexPatterns = () => {
return Promise.resolve([
{
id: 'indexPattern1',
attributes: {
title: 'indexPattern1'
}
},
{
id: 'indexPattern2',
attributes: {
title: 'indexPattern2'
}
}
]);
};
const getIndexPattern = () => {
return Promise.resolve({
fields: [
{ name: 'keywordField', type: 'string', aggregatable: true },
{ name: 'textField', type: 'string', aggregatable: false },
{ name: 'numberField', type: 'number', aggregatable: true }
]
});
};
const controlParams = {
id: '1',
indexPattern: 'indexPattern1',
@ -58,8 +35,8 @@ beforeEach(() => {
test('renders ListControlEditor', () => {
const component = shallow(<ListControlEditor
getIndexPatterns={getIndexPatterns}
getIndexPattern={getIndexPattern}
getIndexPatterns={getIndexPatternsMock}
getIndexPattern={getIndexPatternMock}
controlIndex={0}
controlParams={controlParams}
handleFieldNameChange={handleFieldNameChange}
@ -78,8 +55,8 @@ test('parentCandidates', () => {
{ value: '2', text: 'fieldB' }
];
const component = shallow(<ListControlEditor
getIndexPatterns={getIndexPatterns}
getIndexPattern={getIndexPattern}
getIndexPatterns={getIndexPatternsMock}
getIndexPattern={getIndexPatternMock}
controlIndex={0}
controlParams={controlParams}
handleFieldNameChange={handleFieldNameChange}
@ -94,8 +71,8 @@ test('parentCandidates', () => {
test('handleCheckboxOptionChange - multiselect', () => {
const component = mount(<ListControlEditor
getIndexPatterns={getIndexPatterns}
getIndexPattern={getIndexPattern}
getIndexPatterns={getIndexPatternsMock}
getIndexPattern={getIndexPatternMock}
controlIndex={0}
controlParams={controlParams}
handleFieldNameChange={handleFieldNameChange}
@ -126,8 +103,8 @@ test('handleCheckboxOptionChange - multiselect', () => {
test('handleNumberOptionChange - size', () => {
const component = mount(<ListControlEditor
getIndexPatterns={getIndexPatterns}
getIndexPattern={getIndexPattern}
getIndexPatterns={getIndexPatternsMock}
getIndexPattern={getIndexPatternMock}
controlIndex={0}
controlParams={controlParams}
handleFieldNameChange={handleFieldNameChange}

View file

@ -25,14 +25,15 @@ export function RangeControlEditor(props) {
<div>
<IndexPatternSelect
value={props.controlParams.indexPattern}
indexPatternId={props.controlParams.indexPattern}
onChange={props.handleIndexPatternChange}
getIndexPatterns={props.getIndexPatterns}
getIndexPattern={props.getIndexPattern}
controlIndex={props.controlIndex}
/>
<FieldSelect
value={props.controlParams.fieldName}
fieldName={props.controlParams.fieldName}
indexPatternId={props.controlParams.indexPattern}
filterField={filterField}
onChange={props.handleFieldNameChange}

View file

@ -2,36 +2,13 @@ import React from 'react';
import sinon from 'sinon';
import { mount, shallow } from 'enzyme';
import { findTestSubject } from '@elastic/eui/lib/test';
import { getIndexPatternMock } from './__tests__/get_index_pattern_mock';
import { getIndexPatternsMock } from './__tests__/get_index_patterns_mock';
import {
RangeControlEditor,
} from './range_control_editor';
const getIndexPatterns = () => {
return Promise.resolve([
{
id: 'indexPattern1',
attributes: {
title: 'indexPattern1'
}
},
{
id: 'indexPattern2',
attributes: {
title: 'indexPattern2'
}
}
]);
};
const getIndexPattern = () => {
return Promise.resolve({
fields: [
{ name: 'keywordField', type: 'string', aggregatable: true },
{ name: 'textField', type: 'string', aggregatable: false },
{ name: 'numberField', type: 'number', aggregatable: true }
]
});
};
const controlParams = {
id: '1',
indexPattern: 'indexPattern1',
@ -55,8 +32,8 @@ beforeEach(() => {
test('renders RangeControlEditor', () => {
const component = shallow(<RangeControlEditor
getIndexPatterns={getIndexPatterns}
getIndexPattern={getIndexPattern}
getIndexPatterns={getIndexPatternsMock}
getIndexPattern={getIndexPatternMock}
controlIndex={0}
controlParams={controlParams}
handleFieldNameChange={handleFieldNameChange}
@ -68,8 +45,8 @@ test('renders RangeControlEditor', () => {
test('handleNumberOptionChange - step', () => {
const component = mount(<RangeControlEditor
getIndexPatterns={getIndexPatterns}
getIndexPattern={getIndexPattern}
getIndexPatterns={getIndexPatternsMock}
getIndexPattern={getIndexPatternMock}
controlIndex={0}
controlParams={controlParams}
handleFieldNameChange={handleFieldNameChange}
@ -95,8 +72,8 @@ test('handleNumberOptionChange - step', () => {
test('handleNumberOptionChange - decimalPlaces', () => {
const component = mount(<RangeControlEditor
getIndexPatterns={getIndexPatterns}
getIndexPattern={getIndexPattern}
getIndexPatterns={getIndexPatternsMock}
getIndexPattern={getIndexPatternMock}
controlIndex={0}
controlParams={controlParams}
handleFieldNameChange={handleFieldNameChange}

View file

@ -25,31 +25,24 @@ exports[`Apply and Cancel change btns enabled when there are changes 1`] = `
}
>
<ListControl
control={
Object {
"getMultiSelectDelimiter": [Function],
"id": "mock-list-control",
"isEnabled": [Function],
"label": "list control",
"options": Object {
"multiselect": true,
"type": "terms",
},
"selectOptions": Array [
Object {
"label": "choice1",
"value": "choice1",
},
Object {
"label": "choice2",
"value": "choice2",
},
],
"type": "list",
"value": "",
}
}
controlIndex={0}
disableMsg={null}
id="mock-list-control"
label="list control"
multiselect={true}
options={
Array [
Object {
"label": "choice1",
"value": "choice1",
},
Object {
"label": "choice2",
"value": "choice2",
},
]
}
selectedOptions={Array []}
stageFilter={[Function]}
/>
</EuiFlexItem>
@ -158,31 +151,24 @@ exports[`Clear btns enabled when there are values 1`] = `
}
>
<ListControl
control={
Object {
"getMultiSelectDelimiter": [Function],
"id": "mock-list-control",
"isEnabled": [Function],
"label": "list control",
"options": Object {
"multiselect": true,
"type": "terms",
},
"selectOptions": Array [
Object {
"label": "choice1",
"value": "choice1",
},
Object {
"label": "choice2",
"value": "choice2",
},
],
"type": "list",
"value": "",
}
}
controlIndex={0}
disableMsg={null}
id="mock-list-control"
label="list control"
multiselect={true}
options={
Array [
Object {
"label": "choice1",
"value": "choice1",
},
Object {
"label": "choice2",
"value": "choice2",
},
]
}
selectedOptions={Array []}
stageFilter={[Function]}
/>
</EuiFlexItem>
@ -291,31 +277,24 @@ exports[`Renders list control 1`] = `
}
>
<ListControl
control={
Object {
"getMultiSelectDelimiter": [Function],
"id": "mock-list-control",
"isEnabled": [Function],
"label": "list control",
"options": Object {
"multiselect": true,
"type": "terms",
},
"selectOptions": Array [
Object {
"label": "choice1",
"value": "choice1",
},
Object {
"label": "choice2",
"value": "choice2",
},
],
"type": "list",
"value": "",
}
}
controlIndex={0}
disableMsg={null}
id="mock-list-control"
label="list control"
multiselect={true}
options={
Array [
Object {
"label": "choice1",
"value": "choice1",
},
Object {
"label": "choice2",
"value": "choice2",
},
]
}
selectedOptions={Array []}
stageFilter={[Function]}
/>
</EuiFlexItem>

View file

@ -2,98 +2,31 @@
exports[`renders ListControl 1`] = `
<FormRow
control={
Object {
"getMultiSelectDelimiter": [Function],
"id": "mock-list-control",
"isEnabled": [Function],
"label": "list control",
"options": Object {
"multiselect": true,
"type": "terms",
},
"selectOptions": Array [
Object {
"label": "choice1",
"value": "choice1",
},
Object {
"label": "choice2",
"value": "choice2",
},
],
"type": "list",
"value": "",
}
}
controlIndex={0}
id="mock-list-control"
label="list control"
>
<Select
arrowRenderer={[Function]}
autosize={true}
backspaceRemoves={true}
backspaceToRemoveMessage="Press backspace to remove {label}"
className="list-control-react-select"
clearAllText="Clear all"
clearRenderer={[Function]}
clearValueText="Clear value"
clearable={true}
closeOnSelect={true}
deleteRemoves={true}
delimiter=","
disabled={false}
escapeClearsValue={true}
filterOptions={[Function]}
ignoreAccents={true}
ignoreCase={true}
inputProps={
Object {
"id": "mock-list-control",
}
}
isLoading={false}
joinValues={false}
labelKey="label"
matchPos="any"
matchProp="any"
menuBuffer={0}
menuRenderer={[Function]}
multi={true}
noResultsText="No results found"
onBlurResetsInput={true}
<EuiComboBox
data-test-subj="listControlSelect0"
isClearable={true}
onChange={[Function]}
onCloseResetsInput={true}
onSelectResetsInput={true}
openOnClick={true}
optionComponent={[Function]}
options={
Array [
Object {
"data-test-subj": "option_choice1",
"label": "choice1",
"value": "choice1",
},
Object {
"data-test-subj": "option_choice2",
"label": "choice2",
"value": "choice2",
},
]
}
pageSize={5}
placeholder="Select..."
removeSelected={true}
required={false}
rtl={false}
scrollMenuIntoView={true}
searchable={true}
simpleValue={true}
tabSelectsValue={true}
trimFilter={true}
value=""
valueComponent={[Function]}
valueKey="value"
valueRenderer={[Function]}
selectedOptions={Array []}
singleSelection={false}
/>
</FormRow>
`;

View file

@ -2,26 +2,8 @@
exports[`renders RangeControl 1`] = `
<FormRow
control={
Object {
"hasValue": [Function],
"id": "mock-range-control",
"isEnabled": [Function],
"label": "range control",
"max": 100,
"min": 0,
"options": Object {
"decimalPlaces": 0,
"step": 1,
},
"type": "range",
"value": Object {
"max": 0,
"min": 0,
},
}
}
controlIndex={0}
disableMsg={null}
id="mock-range-control"
label="range control"
>

View file

@ -8,9 +8,9 @@ import {
export function FormRow(props) {
let control = props.children;
if (!props.control.isEnabled()) {
if (props.disableMsg) {
control = (
<EuiToolTip placement="top" content={props.control.disabledReason}>
<EuiToolTip placement="top" content={props.disableMsg}>
{control}
</EuiToolTip>
);
@ -32,5 +32,5 @@ FormRow.propTypes = {
id: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
controlIndex: PropTypes.number.isRequired,
control: PropTypes.object.isRequired,
disableMsg: PropTypes.string,
};

View file

@ -6,15 +6,10 @@ import {
} from './form_row';
test('renders enabled control', () => {
const enabledControl = {
id: 'mock-enabled-control',
isEnabled: () => { return true; },
};
const component = shallow(
<FormRow
label="test control"
id="controlId"
control={enabledControl}
controlIndex={0}
>
<div>My Control</div>
@ -24,16 +19,11 @@ test('renders enabled control', () => {
});
test('renders disabled control with tooltip', () => {
const disabledControl = {
id: 'mock-disabled-control',
isEnabled: () => { return false; },
disabledReason: 'I am disabled for testing purposes'
};
const component = shallow(
<FormRow
label="test control"
id="controlId"
control={disabledControl}
disableMsg="I am disabled for testing purposes"
controlIndex={0}
>
<div>My Control</div>

View file

@ -38,7 +38,12 @@ export class InputControlVis extends Component {
case 'list':
controlComponent = (
<ListControl
control={control}
id={control.id}
label={control.label}
options={control.selectOptions}
selectedOptions={control.value}
disableMsg={control.isEnabled() ? null : control.disabledReason}
multiselect={control.options.multiselect}
controlIndex={index}
stageFilter={this.props.stageFilter}
/>

View file

@ -16,8 +16,7 @@ const mockListControl = {
},
type: 'list',
label: 'list control',
value: '',
getMultiSelectDelimiter: () => { return ','; },
value: [],
selectOptions: [
{ label: 'choice1', value: 'choice1' },
{ label: 'choice2', value: 'choice2' }
@ -159,28 +158,3 @@ test('resetControls', () => {
sinon.assert.notCalled(submitFilters);
sinon.assert.notCalled(stageFilter);
});
test('stageFilter list control', () => {
const component = mount(<InputControlVis
stageFilter={stageFilter}
submitFilters={submitFilters}
resetControls={resetControls}
clearControls={clearControls}
controls={[mockListControl]}
updateFiltersOnChange={updateFiltersOnChange}
hasChanges={() => { return true; }}
hasValues={() => { return true; }}
/>);
const reactSelectInput = component.find(`#${mockListControl.id}`).hostNodes();
reactSelectInput.simulate('change', { target: { value: 'choice1' } });
reactSelectInput.simulate('keyDown', { keyCode: 9, key: 'Tab' });
sinon.assert.notCalled(clearControls);
sinon.assert.notCalled(submitFilters);
sinon.assert.notCalled(resetControls);
const expectedControlIndex = 0;
const expectedControlValue = 'choice1';
sinon.assert.calledWith(stageFilter,
expectedControlIndex,
expectedControlValue
);
});

View file

@ -1,57 +1,44 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Select from 'react-select';
import { FormRow } from './form_row';
import {
EuiFieldText,
EuiComboBox,
} from '@elastic/eui';
export class ListControl extends Component {
constructor(props) {
super(props);
this.handleOnChange = this.handleOnChange.bind(this);
this.truncate = this.truncate.bind(this);
}
handleOnChange(evt) {
let newValue = '';
if (evt) {
newValue = evt;
}
this.props.stageFilter(this.props.controlIndex, newValue);
}
truncate(selected) {
if (selected.label.length <= 24) {
return selected.label;
}
return `${selected.label.substring(0, 23)}...`;
handleOnChange = (selectedOptions) => {
this.props.stageFilter(this.props.controlIndex, selectedOptions);
}
renderControl() {
if (!this.props.control.isEnabled()) {
// react-select clobbers the tooltip, so just returning a disabled input instead
if (this.props.disableMsg) {
return (
<EuiFieldText
placeholder="Select..."
disabled={true}
/>
);
}
const options = this.props.options.map(option => {
return {
label: option.label,
value: option.value,
['data-test-subj']: `option_${option.value.replace(' ', '_')}`
};
});
return (
<Select
className="list-control-react-select"
<EuiComboBox
placeholder="Select..."
multi={this.props.control.options.multiselect}
simpleValue={true}
delimiter={this.props.control.getMultiSelectDelimiter()}
value={this.props.control.value}
options={this.props.control.selectOptions}
options={options}
selectedOptions={this.props.selectedOptions}
onChange={this.handleOnChange}
valueRenderer={this.truncate}
inputProps={{ id: this.props.control.id }}
singleSelection={!this.props.multiselect}
data-test-subj={`listControlSelect${this.props.controlIndex}`}
/>
);
}
@ -59,10 +46,10 @@ export class ListControl extends Component {
render() {
return (
<FormRow
id={this.props.control.id}
label={this.props.control.label}
id={this.props.id}
label={this.props.label}
controlIndex={this.props.controlIndex}
control={this.props.control}
disableMsg={this.props.disableMsg}
>
{this.renderControl()}
</FormRow>
@ -70,8 +57,18 @@ export class ListControl extends Component {
}
}
const comboBoxOptionShape = PropTypes.shape({
label: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
});
ListControl.propTypes = {
control: PropTypes.object.isRequired,
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
selectedOptions: PropTypes.arrayOf(comboBoxOptionShape).isRequired,
options: PropTypes.arrayOf(comboBoxOptionShape).isRequired,
disableMsg: PropTypes.string,
multiselect: PropTypes.bool.isRequired,
controlIndex: PropTypes.number.isRequired,
stageFilter: PropTypes.func.isRequired
};

View file

@ -6,22 +6,11 @@ import {
ListControl,
} from './list_control';
const control = {
id: 'mock-list-control',
isEnabled: () => { return true; },
options: {
type: 'terms',
multiselect: true
},
type: 'list',
label: 'list control',
getMultiSelectDelimiter: () => { return ','; },
value: '',
selectOptions: [
{ label: 'choice1', value: 'choice1' },
{ label: 'choice2', value: 'choice2' }
]
};
const options = [
{ label: 'choice1', value: 'choice1' },
{ label: 'choice2', value: 'choice2' }
];
let stageFilter;
beforeEach(() => {
@ -30,7 +19,11 @@ beforeEach(() => {
test('renders ListControl', () => {
const component = shallow(<ListControl
control={control}
id="mock-list-control"
label="list control"
options={options}
selectedOptions={[]}
multiselect={true}
controlIndex={0}
stageFilter={stageFilter}
/>);

View file

@ -159,7 +159,7 @@ export class RangeControl extends Component {
id={this.props.control.id}
label={this.props.control.label}
controlIndex={this.props.controlIndex}
control={this.props.control}
disableMsg={this.props.control.isEnabled() ? null : this.props.control.disabledReason}
>
{this.renderControl()}
</FormRow>

View file

@ -3,25 +3,23 @@ import { FilterManager } from './filter_manager.js';
import { buildPhraseFilter } from 'ui/filter_manager/lib/phrase';
import { buildPhrasesFilter } from 'ui/filter_manager/lib/phrases';
const EMPTY_VALUE = '';
export class PhraseFilterManager extends FilterManager {
constructor(controlId, fieldName, indexPattern, queryFilter, delimiter) {
super(controlId, fieldName, indexPattern, queryFilter, EMPTY_VALUE);
this.delimiter = delimiter;
constructor(controlId, fieldName, indexPattern, queryFilter) {
super(controlId, fieldName, indexPattern, queryFilter, []);
}
/**
* Convert phrases into filter
*
* @param {string} react-select value (delimiter-separated string of values)
* @param {array}
* @return {object} query filter
* single phrase: match query
* multiple phrases: bool query with should containing list of match_phrase queries
*/
createFilter(value) {
const phrases = value.split(this.delimiter);
createFilter(selectedOptions) {
const phrases = selectedOptions.map(phrase => {
return phrase.value;
});
let newFilter;
if (phrases.length === 1) {
newFilter = buildPhraseFilter(
@ -43,14 +41,25 @@ export class PhraseFilterManager extends FilterManager {
if (kbnFilters.length === 0) {
return this.getUnsetValue();
} else {
const values = kbnFilters
return kbnFilters
.map((kbnFilter) => {
return this._getValueFromFilter(kbnFilter);
})
.reduce((accumulator, currentValue) => {
return accumulator.concat(currentValue);
}, [])
.map(value => {
return { value, label: value };
});
return values.join(this.delimiter);
}
}
/**
* Extract filtering value from kibana filters
*
* @param {object} kbnFilter
* @return {Array.<string>} array of values pulled from filter
*/
_getValueFromFilter(kbnFilter) {
// bool filter - multiple phrase filters
if (_.has(kbnFilter, 'query.bool.should')) {
@ -63,8 +72,7 @@ export class PhraseFilterManager extends FilterManager {
return true;
}
return false;
})
.join(this.delimiter);
});
}
// scripted field filter

View file

@ -24,11 +24,11 @@ describe('PhraseFilterManager', function () {
const queryFilterMock = {};
let filterManager;
beforeEach(() => {
filterManager = new PhraseFilterManager(controlId, 'field1', indexPatternMock, queryFilterMock, '|');
filterManager = new PhraseFilterManager(controlId, 'field1', indexPatternMock, queryFilterMock);
});
test('should create match phrase filter from single value', function () {
const newFilter = filterManager.createFilter('ios');
const newFilter = filterManager.createFilter([{ value: 'ios' }]);
expect(newFilter).to.have.property('meta');
expect(newFilter.meta.index).to.be(indexPatternId);
expect(newFilter.meta.controlledBy).to.be(controlId);
@ -37,7 +37,7 @@ describe('PhraseFilterManager', function () {
});
test('should create bool filter from multiple values', function () {
const newFilter = filterManager.createFilter('ios|win xp');
const newFilter = filterManager.createFilter([{ value: 'ios' }, { value: 'win xp' }]);
expect(newFilter).to.have.property('meta');
expect(newFilter.meta.index).to.be(indexPatternId);
expect(newFilter.meta.controlledBy).to.be(controlId);
@ -67,7 +67,7 @@ describe('PhraseFilterManager', function () {
this.mockFilters = mockFilters;
}
}
filterManager = new MockFindFiltersPhraseFilterManager(controlId, 'field1', indexPatternMock, queryFilterMock, '|');
filterManager = new MockFindFiltersPhraseFilterManager(controlId, 'field1', indexPatternMock, queryFilterMock);
});
test('should extract value from match phrase filter', function () {
@ -83,7 +83,33 @@ describe('PhraseFilterManager', function () {
}
}
]);
expect(filterManager.getValueFromFilterBar()).to.be('ios');
expect(filterManager.getValueFromFilterBar()).to.eql([{ value: 'ios', label: 'ios' }]);
});
test('should extract value from multiple filters', function () {
filterManager.setMockFilters([
{
query: {
match: {
field1: {
query: 'ios',
type: 'phrase'
}
}
}
},
{
query: {
match: {
field1: {
query: 'win xp',
type: 'phrase'
}
}
}
},
]);
expect(filterManager.getValueFromFilterBar()).to.eql([{ value: 'ios', label: 'ios' }, { value: 'win xp', label: 'win xp' }]);
});
test('should extract value from bool filter', function () {
@ -107,7 +133,7 @@ describe('PhraseFilterManager', function () {
}
}
]);
expect(filterManager.getValueFromFilterBar()).to.be('ios|win xp');
expect(filterManager.getValueFromFilterBar()).to.eql([{ value: 'ios', label: 'ios' }, { value: 'win xp', label: 'win xp' }]);
});
});

View file

@ -32,14 +32,8 @@ const termsAgg = (field, size, direction) => {
};
};
const listControlDelimiter = '$$kbn_delimiter$$';
class ListControl extends Control {
getMultiSelectDelimiter() {
return this.filterManager.delimiter;
}
async fetch() {
let ancestorFilters;
if (this.hasAncestors()) {
@ -99,7 +93,7 @@ export async function listControlFactory(controlParams, kbnApi, useTimeFilter) {
return new ListControl(
controlParams,
new PhraseFilterManager(controlParams.id, controlParams.fieldName, indexPattern, kbnApi.queryFilter, listControlDelimiter),
new PhraseFilterManager(controlParams.id, controlParams.fieldName, indexPattern, kbnApi.queryFilter),
kbnApi,
useTimeFilter
);

View file

@ -32,11 +32,3 @@
left: 0% !important;
}
}
visualization.input_control_vis {
overflow: visible;
.vis-container {
overflow: visible;
}
}

View file

@ -705,4 +705,10 @@ style-compile {
display: block;
}
.euiComboBox {
input:focus {
box-shadow: none;
}
}
@import "~dragula/dist/dragula.css";

View file

@ -16,9 +16,9 @@ export default function ({ getService, getPageObjects }) {
await PageObjects.header.setAbsoluteRange('2017-01-01', '2017-01-02');
await PageObjects.visualize.clickVisEditorTab('controls');
await PageObjects.visualize.addInputControl();
await PageObjects.visualize.setReactSelect('.index-pattern-react-select', 'logstash');
await PageObjects.visualize.setComboBox('indexPatternSelect-0', 'logstash');
await PageObjects.common.sleep(1000); // give time for index-pattern to be fetched
await PageObjects.visualize.setReactSelect('.field-react-select', FIELD_NAME);
await PageObjects.visualize.setComboBox('fieldSelect-0', FIELD_NAME);
await PageObjects.visualize.clickGo();
await PageObjects.header.waitUntilLoadingHasFinished();
@ -34,7 +34,7 @@ export default function ({ getService, getPageObjects }) {
describe('updateFiltersOnChange is false', () => {
it('should contain dropdown with terms aggregation results as options', async () => {
const menu = await PageObjects.visualize.getReactSelectOptions('inputControl0');
const menu = await PageObjects.visualize.getComboBoxOptions('listControlSelect0');
expect(menu.trim().split('\n').join()).to.equal('ios,osx,win 7,win 8,win xp');
});
@ -48,10 +48,10 @@ export default function ({ getService, getPageObjects }) {
});
it('should stage filter when item selected but not create filter pill', async () => {
await PageObjects.visualize.setReactSelect('.list-control-react-select', 'ios');
await PageObjects.visualize.setComboBox('listControlSelect0', 'ios');
const dropdownValue = await PageObjects.visualize.getReactSelectValue('.list-control-react-select');
expect(dropdownValue.trim()).to.equal('ios');
const selectedOptions = await PageObjects.visualize.getComboBoxSelectedOptions('listControlSelect0');
expect(selectedOptions[0].trim()).to.equal('ios');
const hasFilter = await filterBar.hasFilter(FIELD_NAME, 'ios');
expect(hasFilter).to.equal(false);
@ -65,8 +65,8 @@ export default function ({ getService, getPageObjects }) {
});
it('should replace existing filter pill(s) when new item is selected', async () => {
await PageObjects.visualize.clearReactSelect('.list-control-react-select');
await PageObjects.visualize.setReactSelect('.list-control-react-select', 'osx');
await PageObjects.visualize.clearComboBox('listControlSelect0');
await PageObjects.visualize.setComboBox('listControlSelect0', 'osx');
await testSubjects.click('inputControlSubmitBtn');
const hasOldFilter = await filterBar.hasFilter(FIELD_NAME, 'ios');
@ -79,18 +79,18 @@ export default function ({ getService, getPageObjects }) {
await filterBar.removeFilter(FIELD_NAME);
await PageObjects.common.sleep(500); // give time for filter to be removed and event handlers to fire
const hasValue = await PageObjects.visualize.doesReactSelectHaveValue('.list-control-react-select');
const hasValue = await PageObjects.visualize.doesComboBoxHaveSelectedOptions('listControlSelect0');
expect(hasValue).to.equal(false);
});
it('should clear form when Clear button is clicked but not remove filter pill', async () => {
await PageObjects.visualize.setReactSelect('.list-control-react-select', 'ios');
await PageObjects.visualize.setComboBox('listControlSelect0', 'ios');
await testSubjects.click('inputControlSubmitBtn');
const hasFilterBeforeClearBtnClicked = await filterBar.hasFilter(FIELD_NAME, 'ios');
expect(hasFilterBeforeClearBtnClicked).to.equal(true);
await testSubjects.click('inputControlClearBtn');
const hasValue = await PageObjects.visualize.doesReactSelectHaveValue('.list-control-react-select');
const hasValue = await PageObjects.visualize.doesComboBoxHaveSelectedOptions('listControlSelect0');
expect(hasValue).to.equal(false);
const hasFilterAfterClearBtnClicked = await filterBar.hasFilter(FIELD_NAME, 'ios');
@ -131,10 +131,10 @@ export default function ({ getService, getPageObjects }) {
});
it('should add filter pill when item selected', async () => {
await PageObjects.visualize.setReactSelect('.list-control-react-select', 'ios');
await PageObjects.visualize.setComboBox('listControlSelect0', 'ios');
const dropdownValue = await PageObjects.visualize.getReactSelectValue('.list-control-react-select');
expect(dropdownValue.trim()).to.equal('ios');
const selectedOptions = await PageObjects.visualize.getComboBoxSelectedOptions('listControlSelect0');
expect(selectedOptions[0].trim()).to.equal('ios');
const hasFilter = await filterBar.hasFilter(FIELD_NAME, 'ios');
expect(hasFilter).to.equal(true);
@ -159,7 +159,7 @@ export default function ({ getService, getPageObjects }) {
await PageObjects.header.waitUntilLoadingHasFinished();
// Expect control to have values for selected time filter
const menu = await PageObjects.visualize.getReactSelectOptions('inputControl0');
const menu = await PageObjects.visualize.getComboBoxOptions('listControlSelect0');
expect(menu.trim().split('\n').join()).to.equal('osx,win 7,win 8,win xp');
});
});
@ -172,14 +172,14 @@ export default function ({ getService, getPageObjects }) {
await PageObjects.visualize.clickVisEditorTab('controls');
await PageObjects.visualize.addInputControl();
await PageObjects.visualize.setReactSelect('#indexPatternSelect-0-row', 'logstash');
await PageObjects.visualize.setComboBox('indexPatternSelect-0', 'logstash');
await PageObjects.common.sleep(1000); // give time for index-pattern to be fetched
await PageObjects.visualize.setReactSelect('#fieldSelect-0-row', 'geo.src');
await PageObjects.visualize.setComboBox('fieldSelect-0', 'geo.src');
await PageObjects.visualize.addInputControl();
await PageObjects.visualize.setReactSelect('#indexPatternSelect-1-row', 'logstash');
await PageObjects.visualize.setComboBox('indexPatternSelect-1', 'logstash');
await PageObjects.common.sleep(1000); // give time for index-pattern to be fetched
await PageObjects.visualize.setReactSelect('#fieldSelect-1-row', 'clientip');
await PageObjects.visualize.setComboBox('fieldSelect-1', 'clientip');
await PageObjects.visualize.setSelectByOptionText('parentSelect-1', 'geo.src');
await PageObjects.visualize.clickGo();
@ -187,7 +187,7 @@ export default function ({ getService, getPageObjects }) {
});
it('should disable child control when parent control is not set', async () => {
const parentControlMenu = await PageObjects.visualize.getReactSelectOptions('inputControl0');
const parentControlMenu = await PageObjects.visualize.getComboBoxOptions('listControlSelect0');
expect(parentControlMenu.trim().split('\n').join()).to.equal('BR,CN,ID,IN,US');
const childControlInput = await find.byCssSelector('[data-test-subj="inputControl1"] input');
@ -196,14 +196,14 @@ export default function ({ getService, getPageObjects }) {
});
it('should filter child control options by parent control value', async () => {
await PageObjects.visualize.setReactSelect('[data-test-subj="inputControl0"]', 'BR');
await PageObjects.visualize.setComboBox('listControlSelect0', 'BR');
const childControlMenu = await PageObjects.visualize.getReactSelectOptions('inputControl1');
const childControlMenu = await PageObjects.visualize.getComboBoxOptions('listControlSelect1');
expect(childControlMenu.trim().split('\n').join()).to.equal('14.61.182.136,3.174.21.181,6.183.121.70,71.241.97.89,9.69.255.135');
});
it('should create a seperate filter pill for parent control and child control', async () => {
await PageObjects.visualize.setReactSelect('[data-test-subj="inputControl1"]', '14.61.182.136');
await PageObjects.visualize.setComboBox('listControlSelect1', '14.61.182.136');
await testSubjects.click('inputControlSubmitBtn');
@ -215,7 +215,7 @@ export default function ({ getService, getPageObjects }) {
});
it('should clear child control dropdown when parent control value is removed', async () => {
await PageObjects.visualize.clearReactSelect('[data-test-subj="inputControl0"]');
await PageObjects.visualize.clearComboBox('listControlSelect0');
await PageObjects.common.sleep(500); // give time for filter to be removed and event handlers to fire
const childControlInput = await find.byCssSelector('[data-test-subj="inputControl1"] input');
@ -229,7 +229,7 @@ export default function ({ getService, getPageObjects }) {
await filterBar.removeFilter('geo.src');
await PageObjects.common.sleep(500); // give time for filter to be removed and event handlers to fire
const hasValue = await PageObjects.visualize.doesReactSelectHaveValue('[data-test-subj="inputControl1"]');
const hasValue = await PageObjects.visualize.doesComboBoxHaveSelectedOptions('listControlSelect0');
expect(hasValue).to.equal(false);
});
});

View file

@ -194,41 +194,57 @@ export function VisualizePageProvider({ getService, getPageObjects }) {
await input.type(timeString);
}
async setReactSelect(className, value) {
const input = await find.byCssSelector(className + ' * input', 0);
async setComboBox(comboBoxSelector, value) {
const comboBox = await testSubjects.find(comboBoxSelector);
const input = await comboBox.findByTagName('input');
await input.clearValue();
await input.type(value);
await find.clickByCssSelector('.Select-option');
const stillOpen = await find.existsByCssSelector('.Select-menu-outer', 0);
if (stillOpen) {
await find.clickByCssSelector(className + ' * .Select-arrow-zone');
}
await find.clickByCssSelector('.euiComboBoxOption');
await this.closeComboBoxOptionsList(comboBox);
await remote.pressKeys('\uE004');
}
async clearReactSelect(className) {
await find.clickByCssSelector(className + ' * .Select-clear-zone');
}
async getReactSelectOptions(containerSelector) {
await testSubjects.click(containerSelector);
async getComboBoxOptions(comboBoxSelector) {
await testSubjects.click(comboBoxSelector);
const menu = await retry.try(
async () => find.byCssSelector('.Select-menu-outer'));
return await menu.getVisibleText();
async () => await testSubjects.find('comboBoxOptionsList'));
const optionsText = await menu.getVisibleText();
const comboBox = await testSubjects.find(comboBoxSelector);
await this.closeComboBoxOptionsList(comboBox);
return optionsText;
}
async doesReactSelectHaveValue(className) {
return await find.existsByCssSelector(className + ' * .Select-value-label', 0);
async doesComboBoxHaveSelectedOptions(comboBoxSelector) {
const comboBox = await testSubjects.find(comboBoxSelector);
const selectedOptions = await comboBox.findAllByClassName('euiComboBoxPill');
return selectedOptions > 0;
}
async getReactSelectValue(className) {
const hasValue = await this.doesReactSelectHaveValue(className);
if (!hasValue) {
return '';
async getComboBoxSelectedOptions(comboBoxSelector) {
const comboBox = await testSubjects.find(comboBoxSelector);
const selectedOptions = await comboBox.findAllByClassName('euiComboBoxPill');
if (selectedOptions.length === 0) {
return [];
}
const valueElement = await retry.try(
async () => find.byCssSelector(className + ' * .Select-value-label'));
return await valueElement.getVisibleText();
const getOptionValuePromises = selectedOptions.map(async (optionElement) => {
return await optionElement.getVisibleText();
});
return await Promise.all(getOptionValuePromises);
}
async clearComboBox(comboBoxSelector) {
const comboBox = await testSubjects.find(comboBoxSelector);
const clearBtn = await comboBox.findByCssSelector('button.euiFormControlLayout__clear');
await clearBtn.click();
}
async closeComboBoxOptionsList(comboBoxElement) {
const isOptionsListOpen = await testSubjects.exists('comboBoxOptionsList');
if (isOptionsListOpen) {
const closeBtn = await comboBoxElement.findByCssSelector('button.euiFormControlLayout__icon');
await closeBtn.click();
}
}
async addInputControl() {