Input Control visualization (#13314) (#14074)

* react editor example

* ensure props are not updated

* use new stageEditorParams method to stage parameter changes

* make component stateless

* use terms_vis_editor component

* get add button to work

* update vis controller to display terms input controls

* update componenent when query bar updates

* add functional test

* lay ground work for different control types in single visulization

* make editors for range and text controls

* text control

* implement type ahead suggestor for text control

* add range slider

* some CSS work

* add submit button, move control init functionallity under control_factory

* add custom options for control types

* provide buttons to move controls up and down

* Make ControlEditor component and clean up styling of editor

* styling work

* multi select for terms dropdown control

* add option to disable filter staging, only enable submit button when filters are staged

* clean up range styling

* rename top level vis folder

* cleanup

* move control type select out of each control editor

* dark theme styling

* use ui/public/filter_manager/lib/phrases.js to build phrases filter, add tests to range filter manager

* use savedObjectsClient to get index patterns

* remove text control and add id to controls for react tracking

* ensure fields get updated when index pattern changes

* update PropTypes for react 15.6.1

* update to latest react-select to avoid isMounted deprecation warnings

* fix input controls functional test

* rename termsControl to listControl to be more generic

* add function test for clear button, refactor directory structure

* functional tests for updateFiltersOnChange is true

* fix react-select clipping problem in dashboard

* try clicking option instead of pressing enter to set react-select value in functional tests

* react-select css

* clean up control_editor component, make ListControlEditor component be function

* add jest test for vis_editor component and accessibility

* add decimal places option to range slider

* add jest test for InputControlVis component

* add default to switch blocks, split editor into seperate tabs, use shallow in snapshot tests

* fix race condition in field_select, update index_pattern_select to fetch indexPatterns on each filter

* clean up control initialization

* use htmlIdGenerator to avoid html element id conflicts

* update functional test to support new editor tabs

* finish jest tests for sub componenets

* mark vis as experimental, refactor buttons for better usability

* fix bug in list control where unable to select options containing numbers and options containing commas. Truncate display of long list options

* fix chart types functional test

* fix jest tests, add margin to action buttons

* remove binds from render functions

* experement with native input range sliders

* Revert "experement with native input range sliders"

This reverts commit aed599e88a.

* Use Promise.resolve in tests and replace _createRequest with searchSource.fetch

* add inputs to range control
This commit is contained in:
Nathan Reese 2017-09-20 07:46:58 -06:00 committed by GitHub
parent 96366861a9
commit 388050ea43
56 changed files with 4242 additions and 4 deletions

View file

@ -173,11 +173,12 @@
"react-color": "2.11.7",
"react-dom": "15.6.1",
"react-input-autosize": "1.1.0",
"react-input-range": "1.2.1",
"react-markdown": "2.4.2",
"react-redux": "4.4.5",
"react-router": "2.0.0",
"react-router-redux": "4.0.4",
"react-select": "1.0.0-rc.1",
"react-select": "1.0.0-rc.5",
"react-sortable": "1.1.0",
"react-toggle": "3.0.1",
"reactcss": "1.0.7",

View file

@ -0,0 +1,9 @@
export default function (kibana) {
return new kibana.Plugin({
uiExports: {
visTypes: [
'plugins/input_control_vis/register_vis'
]
}
});
}

View file

@ -0,0 +1,4 @@
{
"name": "input_control_vis",
"version": "kibana"
}

View file

@ -0,0 +1,95 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders ControlsTab 1`] = `
<div>
<ControlEditor
controlIndex={0}
controlParams={
Object {
"fieldName": "keywordField",
"id": "1",
"indexPattern": "indexPattern1",
"label": "custom label",
"options": Object {
"multiselect": true,
"order": "desc",
"size": 5,
"type": "terms",
},
"type": "list",
}
}
getIndexPattern={[Function]}
getIndexPatterns={[Function]}
handleCheckboxOptionChange={[Function]}
handleFieldNameChange={[Function]}
handleIndexPatternChange={[Function]}
handleLabelChange={[Function]}
handleNumberOptionChange={[Function]}
handleRemoveControl={[Function]}
moveControl={[Function]}
/>
<ControlEditor
controlIndex={1}
controlParams={
Object {
"fieldName": "numberField",
"id": "2",
"indexPattern": "indexPattern1",
"label": "",
"options": Object {
"step": 1,
},
"type": "range",
}
}
getIndexPattern={[Function]}
getIndexPatterns={[Function]}
handleCheckboxOptionChange={[Function]}
handleFieldNameChange={[Function]}
handleIndexPatternChange={[Function]}
handleLabelChange={[Function]}
handleNumberOptionChange={[Function]}
handleRemoveControl={[Function]}
moveControl={[Function]}
/>
<div
className="kuiSideBarFormRow"
>
<div
className="kuiSideBarFormRow__control kuiFieldGroupSection--wide"
>
<select
aria-label="Select control type"
className="kuiSelect"
onChange={[Function]}
value="list"
>
<option
value="range"
>
Range slider
</option>
<option
value="list"
>
Options list
</option>
</select>
</div>
<KuiButton
buttonType="primary"
data-test-subj="inputControlEditorAddBtn"
icon={
<KuiButtonIcon
type="create"
/>
}
onClick={[Function]}
type="button"
>
Add
</KuiButton>
</div>
</div>
`;

View file

@ -0,0 +1,61 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders ListControlEditor 1`] = `
<div>
<IndexPatternSelect
getIndexPatterns={[Function]}
onChange={[Function]}
value="indexPattern1"
/>
<FieldSelect
filterField={[Function]}
getIndexPattern={[Function]}
indexPatternId="indexPattern1"
onChange={[Function]}
value="keywordField"
/>
<div
className="kuiSideBarFormRow"
>
<label
className="kuiSideBarFormRow__label"
htmlFor="multiselect-0"
>
Enable Multiselect
</label>
<div
className="kuiSideBarFormRow__control"
>
<input
checked={true}
className="kuiCheckBox"
id="multiselect-0"
onChange={[Function]}
type="checkbox"
/>
</div>
</div>
<div
className="kuiSideBarFormRow"
>
<label
className="kuiSideBarFormRow__label"
htmlFor="size-0"
>
Size
</label>
<div
className="kuiSideBarFormRow__control kuiFieldGroupSection--wide"
>
<input
className="kuiTextInput"
id="size-0"
min="1"
onChange={[Function]}
type="number"
value={10}
/>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,32 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders OptionsTab 1`] = `
<div>
<div
className="sidebar-item"
>
<div
className="vis-editor-agg-header"
>
<KuiFieldGroup
isAlignedTop={false}
>
<KuiFieldGroupSection
isWide={false}
>
<label>
<input
checked={false}
className="kuiCheckBox"
data-test-subj="inputControlEditorUpdateFiltersOnChangeCheckbox"
onChange={[Function]}
type="checkbox"
/>
Update kibana filters on each change
</label>
</KuiFieldGroupSection>
</KuiFieldGroup>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,61 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders RangeControlEditor 1`] = `
<div>
<IndexPatternSelect
getIndexPatterns={[Function]}
onChange={[Function]}
value="indexPattern1"
/>
<FieldSelect
filterField={[Function]}
getIndexPattern={[Function]}
indexPatternId="indexPattern1"
onChange={[Function]}
value="numberField"
/>
<div
className="kuiSideBarFormRow"
>
<label
className="kuiSideBarFormRow__label"
htmlFor="stepSize-0"
>
Step Size
</label>
<div
className="kuiSideBarFormRow__control kuiFieldGroupSection--wide"
>
<input
className="kuiTextInput"
id="stepSize-0"
onChange={[Function]}
type="number"
value={1}
/>
</div>
</div>
<div
className="kuiSideBarFormRow"
>
<label
className="kuiSideBarFormRow__label"
htmlFor="decimalPlaces-0"
>
Decimal Places
</label>
<div
className="kuiSideBarFormRow__control kuiFieldGroupSection--wide"
>
<input
className="kuiTextInput"
id="decimalPlaces-0"
min="0"
onChange={[Function]}
type="number"
value={0}
/>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,170 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { RangeControlEditor } from './range_control_editor';
import { ListControlEditor } from './list_control_editor';
import { getTitle } from '../../editor_utils';
export class ControlEditor extends Component {
state = {
isEditorCollapsed: true
}
handleToggleControlVisibility = () => {
this.setState(prevState => (
{ isEditorCollapsed: !prevState.isEditorCollapsed }
));
}
changeLabel = (evt) => {
this.props.handleLabelChange(this.props.controlIndex, evt);
}
removeControl = () => {
this.props.handleRemoveControl(this.props.controlIndex);
}
moveUpControl = () => {
this.props.moveControl(this.props.controlIndex, -1);
}
moveDownControl = () => {
this.props.moveControl(this.props.controlIndex, 1);
}
changeIndexPattern = (evt) => {
this.props.handleIndexPatternChange(this.props.controlIndex, evt);
}
changeFieldName = (evt) => {
this.props.handleFieldNameChange(this.props.controlIndex, evt);
}
renderEditor() {
let controlEditor = null;
switch (this.props.controlParams.type) {
case 'list':
controlEditor = (
<ListControlEditor
controlIndex={this.props.controlIndex}
controlParams={this.props.controlParams}
handleIndexPatternChange={this.changeIndexPattern}
handleFieldNameChange={this.changeFieldName}
getIndexPatterns={this.props.getIndexPatterns}
getIndexPattern={this.props.getIndexPattern}
handleNumberOptionChange={this.props.handleNumberOptionChange}
handleCheckboxOptionChange={this.props.handleCheckboxOptionChange}
/>
);
break;
case 'range':
controlEditor = (
<RangeControlEditor
controlIndex={this.props.controlIndex}
controlParams={this.props.controlParams}
handleIndexPatternChange={this.changeIndexPattern}
handleFieldNameChange={this.changeFieldName}
getIndexPatterns={this.props.getIndexPatterns}
getIndexPattern={this.props.getIndexPattern}
handleNumberOptionChange={this.props.handleNumberOptionChange}
/>
);
break;
default:
throw new Error(`Unhandled control editor type ${this.props.controlParams.type}`);
}
const labelId = `controlLabel${this.props.controlIndex}`;
return (
<div>
<div className="kuiSideBarFormRow">
<label className="kuiSideBarFormRow__label" htmlFor={labelId}>
Label
</label>
<div className="kuiSideBarFormRow__control kuiFieldGroupSection--wide">
<input
className="kuiTextInput"
id={labelId}
type="text"
value={this.props.controlParams.label}
onChange={this.changeLabel}
/>
</div>
</div>
{controlEditor}
</div>
);
}
render() {
const visibilityToggleClasses = classNames('fa', {
'fa-caret-right': !this.state.isEditorCollapsed,
'fa-caret-down': this.state.isEditorCollapsed
});
return (
<div className="sidebar-item">
<div className="vis-editor-agg-header">
<button
aria-label={this.state.isEditorCollapsed ? 'Close Editor' : 'Open Editor'}
onClick={this.handleToggleControlVisibility}
type="button"
className="kuiButton kuiButton--primary kuiButton--small vis-editor-agg-header-toggle"
>
<i aria-hidden="true" className={visibilityToggleClasses} />
</button>
<span className="vis-editor-agg-header-title ng-binding">
{getTitle(this.props.controlParams, this.props.controlIndex)}
</span>
<div className="vis-editor-agg-header-controls kuiButtonGroup kuiButtonGroup--united">
<button
aria-label="Move control down"
type="button"
className="kuiButton kuiButton--small"
onClick={this.moveDownControl}
data-test-subj={`inputControlEditorMoveDownControl${this.props.controlIndex}`}
>
<i aria-hidden="true" className="fa fa-chevron-down" />
</button>
<button
aria-label="Move control up"
type="button"
className="kuiButton kuiButton--small"
onClick={this.moveUpControl}
data-test-subj={`inputControlEditorMoveUpControl${this.props.controlIndex}`}
>
<i aria-hidden="true" className="fa fa-chevron-up" />
</button>
<button
aria-label="Remove control"
className="kuiButton kuiButton--danger kuiButton--small"
type="button"
onClick={this.removeControl}
data-test-subj={`inputControlEditorRemoveControl${this.props.controlIndex}`}
>
<i aria-hidden="true" className="fa fa-times" />
</button>
</div>
</div>
{this.state.isEditorCollapsed && this.renderEditor()}
</div>
);
}
}
ControlEditor.propTypes = {
controlIndex: PropTypes.number.isRequired,
controlParams: PropTypes.object.isRequired,
handleLabelChange: PropTypes.func.isRequired,
moveControl: PropTypes.func.isRequired,
handleRemoveControl: PropTypes.func.isRequired,
handleIndexPatternChange: PropTypes.func.isRequired,
handleFieldNameChange: PropTypes.func.isRequired,
getIndexPatterns: PropTypes.func.isRequired,
getIndexPattern: PropTypes.func.isRequired,
handleCheckboxOptionChange: PropTypes.func.isRequired,
handleNumberOptionChange: PropTypes.func.isRequired
};

View file

@ -0,0 +1,136 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { ControlEditor } from './control_editor';
import { KuiButton, KuiButtonIcon } from 'ui_framework/components';
import { addControl, moveControl, newControl, removeControl, setControl } from '../../editor_utils';
export class ControlsTab extends Component {
state = {
type: 'list'
}
getIndexPatterns = async (search) => {
const resp = await this.props.scope.vis.API.savedObjectsClient.find({
type: 'index-pattern',
fields: ['title'],
search: `${search}*`,
search_fields: ['title'],
perPage: 100
});
return resp.savedObjects;
}
getIndexPattern = async (indexPatternId) => {
return await this.props.scope.vis.API.indexPatterns.get(indexPatternId);
}
setVisParam(paramName, paramValue) {
const params = _.cloneDeep(this.props.scope.vis.params);
params[paramName] = paramValue;
this.props.stageEditorParams(params);
}
handleLabelChange = (controlIndex, evt) => {
const updatedControl = this.props.scope.vis.params.controls[controlIndex];
updatedControl.label = evt.target.value;
this.setVisParam('controls', setControl(this.props.scope.vis.params.controls, controlIndex, updatedControl));
}
handleIndexPatternChange = (controlIndex, evt) => {
const updatedControl = this.props.scope.vis.params.controls[controlIndex];
updatedControl.indexPattern = evt.value;
updatedControl.fieldName = '';
this.setVisParam('controls', setControl(this.props.scope.vis.params.controls, controlIndex, updatedControl));
}
handleFieldNameChange = (controlIndex, evt) => {
const updatedControl = this.props.scope.vis.params.controls[controlIndex];
updatedControl.fieldName = evt.value;
this.setVisParam('controls', setControl(this.props.scope.vis.params.controls, controlIndex, updatedControl));
}
handleCheckboxOptionChange = (controlIndex, optionName, evt) => {
const updatedControl = this.props.scope.vis.params.controls[controlIndex];
updatedControl.options[optionName] = evt.target.checked;
this.setVisParam('controls', setControl(this.props.scope.vis.params.controls, controlIndex, updatedControl));
}
handleNumberOptionChange = (controlIndex, optionName, evt) => {
const updatedControl = this.props.scope.vis.params.controls[controlIndex];
updatedControl.options[optionName] = parseFloat(evt.target.value);
this.setVisParam('controls', setControl(this.props.scope.vis.params.controls, controlIndex, updatedControl));
}
handleRemoveControl = (controlIndex) => {
this.setVisParam('controls', removeControl(this.props.scope.vis.params.controls, controlIndex));
}
moveControl = (controlIndex, direction) => {
this.setVisParam('controls', moveControl(this.props.scope.vis.params.controls, controlIndex, direction));
}
handleAddControl = () => {
this.setVisParam('controls', addControl(this.props.scope.vis.params.controls, newControl(this.state.type)));
}
renderControls() {
return this.props.scope.vis.params.controls.map((controlParams, controlIndex) => {
return (
<ControlEditor
key={controlParams.id}
controlIndex={controlIndex}
controlParams={controlParams}
handleLabelChange={this.handleLabelChange}
moveControl={this.moveControl}
handleRemoveControl={this.handleRemoveControl}
handleIndexPatternChange={this.handleIndexPatternChange}
handleFieldNameChange={this.handleFieldNameChange}
getIndexPatterns={this.getIndexPatterns}
getIndexPattern={this.getIndexPattern}
handleCheckboxOptionChange={this.handleCheckboxOptionChange}
handleNumberOptionChange={this.handleNumberOptionChange}
/>
);
});
}
render() {
return (
<div>
{this.renderControls()}
<div className="kuiSideBarFormRow">
<div className="kuiSideBarFormRow__control kuiFieldGroupSection--wide">
<select
aria-label="Select control type"
className="kuiSelect"
value={this.state.type}
onChange={evt => this.setState({ type: evt.target.value })}
>
<option value="range">Range slider</option>
<option value="list">Options list</option>
</select>
</div>
<KuiButton
buttonType="primary"
type="button"
icon={<KuiButtonIcon type="create" />}
onClick={this.handleAddControl}
data-test-subj="inputControlEditorAddBtn"
>
Add
</KuiButton>
</div>
</div>
);
}
}
ControlsTab.propTypes = {
scope: PropTypes.object.isRequired,
stageEditorParams: PropTypes.func.isRequired
};

View file

@ -0,0 +1,191 @@
import React from 'react';
import sinon from 'sinon';
import { mount, shallow } from 'enzyme';
import {
ControlsTab,
} from './controls_tab';
const savedObjectsClientMock = {
find: () => {
return Promise.resolve({
savedObjects: [
{
id: 'indexPattern1',
attributes: {
title: 'title1'
}
}
]
});
}
};
const indexPatternsMock = {
get: () => {
return Promise.resolve({
fields: [
{ name: 'keywordField', type: 'string', aggregatable: true },
{ name: 'numberField', type: 'number', aggregatable: true }
]
});
}
};
const scopeMock = {
vis: {
API: {
savedObjectsClient: savedObjectsClientMock,
indexPatterns: indexPatternsMock
},
params: {
'controls': [
{
'id': '1',
'indexPattern': 'indexPattern1',
'fieldName': 'keywordField',
'label': 'custom label',
'type': 'list',
'options': {
'type': 'terms',
'multiselect': true,
'size': 5,
'order': 'desc'
}
},
{
'id': '2',
'indexPattern': 'indexPattern1',
'fieldName': 'numberField',
'label': '',
'type': 'range',
'options': {
'step': 1
}
}
]
}
}
};
let stageEditorParams;
beforeEach(() => {
stageEditorParams = sinon.spy();
});
test('renders ControlsTab', () => {
const component = shallow(<ControlsTab
scope={scopeMock}
stageEditorParams={stageEditorParams}
/>);
expect(component).toMatchSnapshot(); // eslint-disable-line
});
test('add control btn', () => {
const component = mount(<ControlsTab
scope={scopeMock}
stageEditorParams={stageEditorParams}
/>);
component.find('[data-test-subj="inputControlEditorAddBtn"]').simulate('click');
// Use custom match function since control.id is dynamically generated and never the same.
sinon.assert.calledWith(stageEditorParams, sinon.match((newParams) => {
if (newParams.controls.length !== 3) {
return false;
}
return true;
}, 'control not added to vis.params'));
});
test('remove control btn', () => {
const component = mount(<ControlsTab
scope={scopeMock}
stageEditorParams={stageEditorParams}
/>);
component.find('[data-test-subj="inputControlEditorRemoveControl0"]').simulate('click');
const expectedParams = {
'controls': [
{
'id': '2',
'indexPattern': 'indexPattern1',
'fieldName': 'numberField',
'label': '',
'type': 'range',
'options': {
'step': 1
}
}
]
};
sinon.assert.calledWith(stageEditorParams, sinon.match(expectedParams));
});
test('move down control btn', () => {
const component = mount(<ControlsTab
scope={scopeMock}
stageEditorParams={stageEditorParams}
/>);
component.find('[data-test-subj="inputControlEditorMoveDownControl0"]').simulate('click');
const expectedParams = {
'controls': [
{
'id': '2',
'indexPattern': 'indexPattern1',
'fieldName': 'numberField',
'label': '',
'type': 'range',
'options': {
'step': 1
}
},
{
'id': '1',
'indexPattern': 'indexPattern1',
'fieldName': 'keywordField',
'label': 'custom label',
'type': 'list',
'options': {
'type': 'terms',
'multiselect': true,
'size': 5,
'order': 'desc'
}
}
]
};
sinon.assert.calledWith(stageEditorParams, sinon.match(expectedParams));
});
test('move up control btn', () => {
const component = mount(<ControlsTab
scope={scopeMock}
stageEditorParams={stageEditorParams}
/>);
component.find('[data-test-subj="inputControlEditorMoveUpControl1"]').simulate('click');
const expectedParams = {
'controls': [
{
'id': '2',
'indexPattern': 'indexPattern1',
'fieldName': 'numberField',
'label': '',
'type': 'range',
'options': {
'step': 1
}
},
{
'id': '1',
'indexPattern': 'indexPattern1',
'fieldName': 'keywordField',
'label': 'custom label',
'type': 'list',
'options': {
'type': 'terms',
'multiselect': true,
'size': 5,
'order': 'desc'
}
}
]
};
sinon.assert.calledWith(stageEditorParams, sinon.match(expectedParams));
});

View file

@ -0,0 +1,91 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Select from 'react-select';
import { htmlIdGenerator } from 'ui_framework/services';
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.state = {
fields: []
};
this.filterField = _.get(props, 'filterField', () => { return true; });
this.loadFields(props.indexPatternId);
}
componentWillReceiveProps(nextProps) {
if (this.props.indexPatternId !== nextProps.indexPatternId) {
this.activeIndexPatternId = nextProps.indexPatternId;
this.setState({ fields: [] });
this.loadFields(nextProps.indexPatternId);
}
}
async loadFields(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) {
return;
}
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 };
});
this.setState({ fields: fields });
}
render() {
if (!this.props.indexPatternId || this.props.indexPatternId.trim().length === 0) {
return null;
}
const idGenerator = htmlIdGenerator();
const selectId = idGenerator('indexPatternSelect');
return (
<div className="kuiSideBarFormRow">
<label className="kuiSideBarFormRow__label" htmlFor={selectId}>
Field
</label>
<div className="kuiSideBarFormRow__control kuiFieldGroupSection--wide">
<Select
className="field-react-select"
placeholder="Select field..."
value={this.props.value}
options={this.state.fields}
onChange={this.props.onChange}
resetValue={''}
inputProps={{ id: selectId }}
/>
</div>
</div>
);
}
}
FieldSelect.propTypes = {
getIndexPattern: PropTypes.func.isRequired,
indexPatternId: PropTypes.string,
onChange: PropTypes.func.isRequired,
value: PropTypes.string,
filterField: PropTypes.func
};

View file

@ -0,0 +1,53 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Select from 'react-select';
import { htmlIdGenerator } from 'ui_framework/services';
export class IndexPatternSelect extends Component {
constructor(props) {
super(props);
this.loadOptions = this.loadOptions.bind(this);
}
loadOptions(input, callback) {
this.props.getIndexPatterns(input).then((indexPatternSavedObjects) => {
const options = indexPatternSavedObjects.map((indexPatternSavedObject) => {
return {
label: indexPatternSavedObject.attributes.title,
value: indexPatternSavedObject.id
};
});
callback(null, { options: options });
});
}
render() {
const idGenerator = htmlIdGenerator();
const selectId = idGenerator('indexPatternSelect');
return (
<div className="kuiSideBarFormRow">
<label className="kuiSideBarFormRow__label" htmlFor={selectId}>
Index Pattern
</label>
<div className="kuiSideBarFormRow__control kuiFieldGroupSection--wide">
<Select.Async
className="index-pattern-react-select"
placeholder="Select index pattern..."
value={this.props.value}
loadOptions={this.loadOptions}
onChange={this.props.onChange}
resetValue={''}
inputProps={{ id: selectId }}
/>
</div>
</div>
);
}
}
IndexPatternSelect.propTypes = {
getIndexPatterns: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
value: PropTypes.string
};

View file

@ -0,0 +1,81 @@
import PropTypes from 'prop-types';
import React from 'react';
import { IndexPatternSelect } from './index_pattern_select';
import { FieldSelect } from './field_select';
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);
};
return (
<div>
<IndexPatternSelect
value={props.controlParams.indexPattern}
onChange={props.handleIndexPatternChange}
getIndexPatterns={props.getIndexPatterns}
/>
<FieldSelect
value={props.controlParams.fieldName}
indexPatternId={props.controlParams.indexPattern}
filterField={filterField}
onChange={props.handleFieldNameChange}
getIndexPattern={props.getIndexPattern}
/>
<div className="kuiSideBarFormRow">
<label className="kuiSideBarFormRow__label" htmlFor={multiselectId}>
Enable Multiselect
</label>
<div className="kuiSideBarFormRow__control">
<input
id={multiselectId}
className="kuiCheckBox"
type="checkbox"
checked={props.controlParams.options.multiselect}
onChange={handleMultiselectChange}
/>
</div>
</div>
<div className="kuiSideBarFormRow">
<label className="kuiSideBarFormRow__label" htmlFor={sizeId}>
Size
</label>
<div className="kuiSideBarFormRow__control kuiFieldGroupSection--wide">
<input
id={sizeId}
className="kuiTextInput"
type="number"
min="1"
value={props.controlParams.options.size}
onChange={handleSizeChange}
/>
</div>
</div>
</div>
);
}
ListControlEditor.propTypes = {
getIndexPatterns: PropTypes.func.isRequired,
getIndexPattern: PropTypes.func.isRequired,
controlIndex: PropTypes.number.isRequired,
controlParams: PropTypes.object.isRequired,
handleFieldNameChange: PropTypes.func.isRequired,
handleIndexPatternChange: PropTypes.func.isRequired,
handleCheckboxOptionChange: PropTypes.func.isRequired,
handleNumberOptionChange: PropTypes.func.isRequired
};

View file

@ -0,0 +1,132 @@
import React from 'react';
import sinon from 'sinon';
import { mount, shallow } from 'enzyme';
import {
ListControlEditor,
} from './list_control_editor';
const getIndexPatterns = () => {
return Promise.resolve({
savedObjects: [
{
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',
fieldName: 'keywordField',
label: 'custom label',
type: 'list',
options: {
type: 'terms',
multiselect: true,
size: 10
}
};
let handleFieldNameChange;
let handleIndexPatternChange;
let handleCheckboxOptionChange;
let handleNumberOptionChange;
beforeEach(() => {
handleFieldNameChange = sinon.spy();
handleIndexPatternChange = sinon.spy();
handleCheckboxOptionChange = sinon.spy();
handleNumberOptionChange = sinon.spy();
});
test('renders ListControlEditor', () => {
const component = shallow(<ListControlEditor
getIndexPatterns={getIndexPatterns}
getIndexPattern={getIndexPattern}
controlIndex={0}
controlParams={controlParams}
handleFieldNameChange={handleFieldNameChange}
handleIndexPatternChange={handleIndexPatternChange}
handleCheckboxOptionChange={handleCheckboxOptionChange}
handleNumberOptionChange={handleNumberOptionChange}
/>);
expect(component).toMatchSnapshot(); // eslint-disable-line
});
test('handleCheckboxOptionChange - multiselect', () => {
const component = mount(<ListControlEditor
getIndexPatterns={getIndexPatterns}
getIndexPattern={getIndexPattern}
controlIndex={0}
controlParams={controlParams}
handleFieldNameChange={handleFieldNameChange}
handleIndexPatternChange={handleIndexPatternChange}
handleCheckboxOptionChange={handleCheckboxOptionChange}
handleNumberOptionChange={handleNumberOptionChange}
/>);
const checkbox = component.find('#multiselect-0');
checkbox.simulate('change', { target: { checked: true } });
sinon.assert.notCalled(handleFieldNameChange);
sinon.assert.notCalled(handleIndexPatternChange);
sinon.assert.notCalled(handleNumberOptionChange);
const expectedControlIndex = 0;
const expectedOptionName = 'multiselect';
sinon.assert.calledWith(
handleCheckboxOptionChange,
expectedControlIndex,
expectedOptionName,
sinon.match((evt) => {
if (evt.target.checked === true) {
return true;
}
return false;
}, 'unexpected checkbox input event'));
});
test('handleNumberOptionChange - size', () => {
const component = mount(<ListControlEditor
getIndexPatterns={getIndexPatterns}
getIndexPattern={getIndexPattern}
controlIndex={0}
controlParams={controlParams}
handleFieldNameChange={handleFieldNameChange}
handleIndexPatternChange={handleIndexPatternChange}
handleCheckboxOptionChange={handleCheckboxOptionChange}
handleNumberOptionChange={handleNumberOptionChange}
/>);
const input = component.find('#size-0');
input.simulate('change', { target: { value: 7 } });
sinon.assert.notCalled(handleCheckboxOptionChange);
sinon.assert.notCalled(handleFieldNameChange);
sinon.assert.notCalled(handleIndexPatternChange);
const expectedControlIndex = 0;
const expectedOptionName = 'size';
sinon.assert.calledWith(
handleNumberOptionChange,
expectedControlIndex,
expectedOptionName,
sinon.match((evt) => {
if (evt.target.value === 7) {
return true;
}
return false;
}, 'unexpected input event'));
});

View file

@ -0,0 +1,54 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { KuiFieldGroup, KuiFieldGroupSection } from 'ui_framework/components';
export class OptionsTab extends Component {
constructor(props) {
super(props);
this.handleUpdateFiltersChange = this.handleUpdateFiltersChange.bind(this);
}
setVisParam(paramName, paramValue) {
const params = _.cloneDeep(this.props.scope.vis.params);
params[paramName] = paramValue;
this.props.stageEditorParams(params);
}
handleUpdateFiltersChange(evt) {
this.setVisParam('updateFiltersOnChange', evt.target.checked);
}
render() {
return (
<div>
<div className="sidebar-item">
<div className="vis-editor-agg-header">
<KuiFieldGroup>
<KuiFieldGroupSection>
<label>
<input
className="kuiCheckBox"
type="checkbox"
checked={this.props.scope.vis.params.updateFiltersOnChange}
onChange={this.handleUpdateFiltersChange}
data-test-subj="inputControlEditorUpdateFiltersOnChangeCheckbox"
/>
Update kibana filters on each change
</label>
</KuiFieldGroupSection>
</KuiFieldGroup>
</div>
</div>
</div>
);
}
}
OptionsTab.propTypes = {
scope: PropTypes.object.isRequired,
stageEditorParams: PropTypes.func.isRequired
};

View file

@ -0,0 +1,42 @@
import React from 'react';
import sinon from 'sinon';
import { mount, shallow } from 'enzyme';
import {
OptionsTab,
} from './options_tab';
const scopeMock = {
vis: {
params: {
updateFiltersOnChange: false
}
}
};
let stageEditorParams;
beforeEach(() => {
stageEditorParams = sinon.spy();
});
test('renders OptionsTab', () => {
const component = shallow(<OptionsTab
scope={scopeMock}
stageEditorParams={stageEditorParams}
/>);
expect(component).toMatchSnapshot(); // eslint-disable-line
});
test('updateFiltersOnChange', () => {
const component = mount(<OptionsTab
scope={scopeMock}
stageEditorParams={stageEditorParams}
/>);
const checkbox = component.find('[data-test-subj="inputControlEditorUpdateFiltersOnChangeCheckbox"]');
checkbox.simulate('change', { target: { checked: true } });
const expectedParams = {
updateFiltersOnChange: true
};
sinon.assert.calledOnce(stageEditorParams);
sinon.assert.calledWith(stageEditorParams, sinon.match(expectedParams));
});

View file

@ -0,0 +1,79 @@
import PropTypes from 'prop-types';
import React from 'react';
import { IndexPatternSelect } from './index_pattern_select';
import { FieldSelect } from './field_select';
function filterField(field) {
return field.type === 'number';
}
export function RangeControlEditor(props) {
const stepSizeId = `stepSize-${props.controlIndex}`;
const decimalPlacesId = `decimalPlaces-${props.controlIndex}`;
const handleDecimalPlacesChange = (evt) => {
props.handleNumberOptionChange(props.controlIndex, 'decimalPlaces', evt);
};
const handleStepChange = (evt) => {
props.handleNumberOptionChange(props.controlIndex, 'step', evt);
};
return (
<div>
<IndexPatternSelect
value={props.controlParams.indexPattern}
onChange={props.handleIndexPatternChange}
getIndexPatterns={props.getIndexPatterns}
/>
<FieldSelect
value={props.controlParams.fieldName}
indexPatternId={props.controlParams.indexPattern}
filterField={filterField}
onChange={props.handleFieldNameChange}
getIndexPattern={props.getIndexPattern}
/>
<div className="kuiSideBarFormRow">
<label className="kuiSideBarFormRow__label" htmlFor={stepSizeId}>
Step Size
</label>
<div className="kuiSideBarFormRow__control kuiFieldGroupSection--wide">
<input
id={stepSizeId}
className="kuiTextInput"
type="number"
value={props.controlParams.options.step}
onChange={handleStepChange}
/>
</div>
</div>
<div className="kuiSideBarFormRow">
<label className="kuiSideBarFormRow__label" htmlFor={decimalPlacesId}>
Decimal Places
</label>
<div className="kuiSideBarFormRow__control kuiFieldGroupSection--wide">
<input
id={decimalPlacesId}
className="kuiTextInput"
type="number"
min="0"
value={props.controlParams.options.decimalPlaces}
onChange={handleDecimalPlacesChange}
/>
</div>
</div>
</div>
);
}
RangeControlEditor.propTypes = {
getIndexPatterns: PropTypes.func.isRequired,
getIndexPattern: PropTypes.func.isRequired,
controlIndex: PropTypes.number.isRequired,
controlParams: PropTypes.object.isRequired,
handleFieldNameChange: PropTypes.func.isRequired,
handleIndexPatternChange: PropTypes.func.isRequired,
handleNumberOptionChange: PropTypes.func.isRequired
};

View file

@ -0,0 +1,124 @@
import React from 'react';
import sinon from 'sinon';
import { mount, shallow } from 'enzyme';
import {
RangeControlEditor,
} from './range_control_editor';
const getIndexPatterns = () => {
return Promise.resolve({
savedObjects: [
{
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',
fieldName: 'numberField',
label: 'custom label',
type: 'range',
options: {
decimalPlaces: 0,
step: 1
}
};
let handleFieldNameChange;
let handleIndexPatternChange;
let handleNumberOptionChange;
beforeEach(() => {
handleFieldNameChange = sinon.spy();
handleIndexPatternChange = sinon.spy();
handleNumberOptionChange = sinon.spy();
});
test('renders RangeControlEditor', () => {
const component = shallow(<RangeControlEditor
getIndexPatterns={getIndexPatterns}
getIndexPattern={getIndexPattern}
controlIndex={0}
controlParams={controlParams}
handleFieldNameChange={handleFieldNameChange}
handleIndexPatternChange={handleIndexPatternChange}
handleNumberOptionChange={handleNumberOptionChange}
/>);
expect(component).toMatchSnapshot(); // eslint-disable-line
});
test('handleNumberOptionChange - step', () => {
const component = mount(<RangeControlEditor
getIndexPatterns={getIndexPatterns}
getIndexPattern={getIndexPattern}
controlIndex={0}
controlParams={controlParams}
handleFieldNameChange={handleFieldNameChange}
handleIndexPatternChange={handleIndexPatternChange}
handleNumberOptionChange={handleNumberOptionChange}
/>);
const input = component.find('#stepSize-0');
input.simulate('change', { target: { value: 0.5 } });
sinon.assert.notCalled(handleFieldNameChange);
sinon.assert.notCalled(handleIndexPatternChange);
const expectedControlIndex = 0;
const expectedOptionName = 'step';
sinon.assert.calledWith(
handleNumberOptionChange,
expectedControlIndex,
expectedOptionName,
sinon.match((evt) => {
if (evt.target.value === 0.5) {
return true;
}
return false;
}, 'unexpected input event'));
});
test('handleNumberOptionChange - decimalPlaces', () => {
const component = mount(<RangeControlEditor
getIndexPatterns={getIndexPatterns}
getIndexPattern={getIndexPattern}
controlIndex={0}
controlParams={controlParams}
handleFieldNameChange={handleFieldNameChange}
handleIndexPatternChange={handleIndexPatternChange}
handleNumberOptionChange={handleNumberOptionChange}
/>);
const input = component.find('#decimalPlaces-0');
input.simulate('change', { target: { value: 2 } });
sinon.assert.notCalled(handleFieldNameChange);
sinon.assert.notCalled(handleIndexPatternChange);
const expectedControlIndex = 0;
const expectedOptionName = 'decimalPlaces';
sinon.assert.calledWith(
handleNumberOptionChange,
expectedControlIndex,
expectedOptionName,
sinon.match((evt) => {
if (evt.target.value === 2) {
return true;
}
return false;
}, 'unexpected input event'));
});

View file

@ -0,0 +1,69 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders ListControl 1`] = `
<Component
id="mock-list-control"
label="list control"
>
<Select
addLabelText="Add \\"{label}\\"?"
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}
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}
onChange={[Function]}
onCloseResetsInput={true}
optionComponent={[Function]}
options={
Array [
Object {
"label": "choice1",
"value": "choice1",
},
Object {
"label": "choice2",
"value": "choice2",
},
]
}
pageSize={5}
placeholder="Select..."
required={false}
scrollMenuIntoView={true}
searchable={true}
simpleValue={true}
tabSelectsValue={true}
value=""
valueComponent={[Function]}
valueKey="value"
valueRenderer={[Function]}
/>
</Component>
`;

View file

@ -0,0 +1,64 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders RangeControl 1`] = `
<Component
id="mock-range-control"
label="range control"
>
<input
className="kuiTextInput"
id="mock-range-control_min"
max={100}
min={0}
name="min"
onChange={[Function]}
type="number"
value=""
/>
<div
className="inputRangeContainer"
>
<InputRange
ariaLabelledby="mock-range-control"
classNames={
Object {
"activeTrack": "input-range__track input-range__track--active",
"disabledInputRange": "input-range input-range--disabled",
"inputRange": "input-range",
"labelContainer": "input-range__label-container",
"maxLabel": "input-range__label input-range__label--max",
"minLabel": "input-range__label input-range__label--min",
"slider": "input-range__slider",
"sliderContainer": "input-range__slider-container",
"track": "input-range__track input-range__track--background",
"valueLabel": "input-range__label input-range__label--value",
}
}
disabled={false}
draggableTrack={true}
formatLabel={[Function]}
maxValue={100}
minValue={0}
onChange={[Function]}
onChangeComplete={[Function]}
step={1}
value={
Object {
"max": 0,
"min": 0,
}
}
/>
</div>
<input
className="kuiTextInput"
id="mock-range-control_max"
max={100}
min={0}
name="max"
onChange={[Function]}
type="number"
value=""
/>
</Component>
`;

View file

@ -0,0 +1,323 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Apply and Cancel change btns enabled when there are changes 1`] = `
<div
className="inputControlVis"
>
<div
data-test-subj="inputControl0"
>
<ListControl
control={
Object {
"getMultiSelectDelimiter": [Function],
"id": "mock-list-control",
"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}
stageFilter={[Function]}
/>
</div>
<KuiFieldGroup
className="actions"
isAlignedTop={false}
>
<KuiFieldGroupSection
isWide={false}
>
<KuiButton
buttonType="primary"
data-test-subj="inputControlSubmitBtn"
disabled={false}
onClick={[Function]}
type="button"
>
Apply changes
</KuiButton>
</KuiFieldGroupSection>
<KuiFieldGroupSection
isWide={false}
>
<KuiButton
buttonType="basic"
data-test-subj="inputControlCancelBtn"
disabled={false}
onClick={[Function]}
type="button"
>
Cancel changes
</KuiButton>
</KuiFieldGroupSection>
<KuiFieldGroupSection
isWide={false}
>
<KuiButton
buttonType="basic"
data-test-subj="inputControlClearBtn"
disabled={true}
onClick={[Function]}
type="button"
>
Clear form
</KuiButton>
</KuiFieldGroupSection>
</KuiFieldGroup>
</div>
`;
exports[`Clear btns enabled when there are values 1`] = `
<div
className="inputControlVis"
>
<div
data-test-subj="inputControl0"
>
<ListControl
control={
Object {
"getMultiSelectDelimiter": [Function],
"id": "mock-list-control",
"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}
stageFilter={[Function]}
/>
</div>
<KuiFieldGroup
className="actions"
isAlignedTop={false}
>
<KuiFieldGroupSection
isWide={false}
>
<KuiButton
buttonType="primary"
data-test-subj="inputControlSubmitBtn"
disabled={true}
onClick={[Function]}
type="button"
>
Apply changes
</KuiButton>
</KuiFieldGroupSection>
<KuiFieldGroupSection
isWide={false}
>
<KuiButton
buttonType="basic"
data-test-subj="inputControlCancelBtn"
disabled={true}
onClick={[Function]}
type="button"
>
Cancel changes
</KuiButton>
</KuiFieldGroupSection>
<KuiFieldGroupSection
isWide={false}
>
<KuiButton
buttonType="basic"
data-test-subj="inputControlClearBtn"
disabled={false}
onClick={[Function]}
type="button"
>
Clear form
</KuiButton>
</KuiFieldGroupSection>
</KuiFieldGroup>
</div>
`;
exports[`Renders list control 1`] = `
<div
className="inputControlVis"
>
<div
data-test-subj="inputControl0"
>
<ListControl
control={
Object {
"getMultiSelectDelimiter": [Function],
"id": "mock-list-control",
"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}
stageFilter={[Function]}
/>
</div>
<KuiFieldGroup
className="actions"
isAlignedTop={false}
>
<KuiFieldGroupSection
isWide={false}
>
<KuiButton
buttonType="primary"
data-test-subj="inputControlSubmitBtn"
disabled={true}
onClick={[Function]}
type="button"
>
Apply changes
</KuiButton>
</KuiFieldGroupSection>
<KuiFieldGroupSection
isWide={false}
>
<KuiButton
buttonType="basic"
data-test-subj="inputControlCancelBtn"
disabled={true}
onClick={[Function]}
type="button"
>
Cancel changes
</KuiButton>
</KuiFieldGroupSection>
<KuiFieldGroupSection
isWide={false}
>
<KuiButton
buttonType="basic"
data-test-subj="inputControlClearBtn"
disabled={true}
onClick={[Function]}
type="button"
>
Clear form
</KuiButton>
</KuiFieldGroupSection>
</KuiFieldGroup>
</div>
`;
exports[`Renders range control 1`] = `
<div
className="inputControlVis"
>
<div
data-test-subj="inputControl0"
>
<RangeControl
control={
Object {
"id": "mock-range-control",
"label": "ragne control",
"max": 100,
"min": 0,
"options": Object {
"decimalPlaces": 0,
"step": 1,
},
"type": "range",
"value": Object {
"max": 0,
"min": 0,
},
}
}
controlIndex={0}
stageFilter={[Function]}
/>
</div>
<KuiFieldGroup
className="actions"
isAlignedTop={false}
>
<KuiFieldGroupSection
isWide={false}
>
<KuiButton
buttonType="primary"
data-test-subj="inputControlSubmitBtn"
disabled={true}
onClick={[Function]}
type="button"
>
Apply changes
</KuiButton>
</KuiFieldGroupSection>
<KuiFieldGroupSection
isWide={false}
>
<KuiButton
buttonType="basic"
data-test-subj="inputControlCancelBtn"
disabled={true}
onClick={[Function]}
type="button"
>
Cancel changes
</KuiButton>
</KuiFieldGroupSection>
<KuiFieldGroupSection
isWide={false}
>
<KuiButton
buttonType="basic"
data-test-subj="inputControlClearBtn"
disabled={true}
onClick={[Function]}
type="button"
>
Clear form
</KuiButton>
</KuiFieldGroupSection>
</KuiFieldGroup>
</div>
`;

View file

@ -0,0 +1,19 @@
import PropTypes from 'prop-types';
import React from 'react';
export const FormRow = (props) => (
<div className="kuiVerticalRhythm">
<label className="kuiLabel kuiVerticalRhythmSmall" htmlFor={props.id}>
{props.label}
</label>
<div className="kuiVerticalRhythmSmall">
{props.children}
</div>
</div>
);
FormRow.propTypes = {
label: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
children: PropTypes.node.isRequired
};

View file

@ -0,0 +1,56 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Select from 'react-select';
import { FormRow } from './form_row';
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)}...`;
}
render() {
return (
<FormRow
id={this.props.control.id}
label={this.props.control.label}
>
<Select
className="list-control-react-select"
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}
onChange={this.handleOnChange}
valueRenderer={this.truncate}
inputProps={{ id: this.props.control.id }}
/>
</FormRow>
);
}
}
ListControl.propTypes = {
control: PropTypes.object.isRequired,
controlIndex: PropTypes.number.isRequired,
stageFilter: PropTypes.func.isRequired
};

View file

@ -0,0 +1,37 @@
import React from 'react';
import sinon from 'sinon';
import { shallow } from 'enzyme';
import {
ListControl,
} from './list_control';
const control = {
id: 'mock-list-control',
options: {
type: 'terms',
multiselect: true
},
type: 'list',
label: 'list control',
getMultiSelectDelimiter: () => { return ','; },
value: '',
selectOptions: [
{ label: 'choice1', value: 'choice1' },
{ label: 'choice2', value: 'choice2' }
]
};
let stageFilter;
beforeEach(() => {
stageFilter = sinon.spy();
});
test('renders ListControl', () => {
const component = shallow(<ListControl
control={control}
controlIndex={0}
stageFilter={stageFilter}
/>);
expect(component).toMatchSnapshot(); // eslint-disable-line
});

View file

@ -0,0 +1,130 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import InputRange from 'react-input-range';
import { FormRow } from './form_row';
const toState = (props) => {
const state = {
sliderValue: props.control.value,
minValue: '',
maxValue: ''
};
if (props.control.hasValue()) {
state.minValue = props.control.value.min;
state.maxValue = props.control.value.max;
}
return state;
};
export class RangeControl extends Component {
constructor(props) {
super(props);
this.state = toState(props);
}
componentWillReceiveProps = (nextProps) => {
this.setState(toState(nextProps));
}
handleOnChange = (value) => {
this.setState({
sliderValue: value,
minValue: value.min,
maxValue: value.max
});
}
handleOnChangeComplete = (value) => {
this.props.stageFilter(this.props.controlIndex, value);
}
handleInputChange = (evt) => {
let inputValue = parseFloat(evt.target.value);
if (inputValue < this.props.control.min) {
inputValue = this.props.control.min;
} else if (inputValue > this.props.control.max) {
inputValue = this.props.control.max;
}
let otherValue;
if ('min' === evt.target.name) {
otherValue = this.props.control.value.max;
} else {
otherValue = this.props.control.value.min;
}
let min;
let max;
if (inputValue < otherValue) {
min = inputValue;
max = otherValue;
} else {
min = otherValue;
max = inputValue;
}
this.handleOnChangeComplete({
min: min,
max: max
});
}
formatLabel = (value) => {
let formatedValue = value;
const decimalPlaces = _.get(this.props, 'control.options.decimalPlaces');
if (decimalPlaces !== null && decimalPlaces >= 0) {
formatedValue = value.toFixed(decimalPlaces);
}
return formatedValue;
}
render() {
return (
<FormRow
id={this.props.control.id}
label={this.props.control.label}
>
<input
id={`${this.props.control.id}_min`}
name="min"
type="number"
className="kuiTextInput"
value={this.state.minValue}
min={this.props.control.min}
max={this.props.control.max}
onChange={this.handleInputChange}
/>
<div className="inputRangeContainer">
<InputRange
maxValue={this.props.control.max}
minValue={this.props.control.min}
step={this.props.control.options.step}
value={this.state.sliderValue}
onChange={this.handleOnChange}
onChangeComplete={this.handleOnChangeComplete}
draggableTrack={true}
ariaLabelledby={this.props.control.id}
formatLabel={this.formatLabel}
/>
</div>
<input
id={`${this.props.control.id}_max`}
name="max"
type="number"
className="kuiTextInput"
value={this.state.maxValue}
min={this.props.control.min}
max={this.props.control.max}
onChange={this.handleInputChange}
/>
</FormRow>
);
}
}
RangeControl.propTypes = {
control: PropTypes.object.isRequired,
controlIndex: PropTypes.number.isRequired,
stageFilter: PropTypes.func.isRequired
};

View file

@ -0,0 +1,37 @@
import React from 'react';
import sinon from 'sinon';
import { shallow } from 'enzyme';
import {
RangeControl,
} from './range_control';
const control = {
id: 'mock-range-control',
options: {
decimalPlaces: 0,
step: 1
},
type: 'range',
label: 'range control',
value: { min: 0, max: 0 },
min: 0,
max: 100,
hasValue: () => {
return false;
}
};
let stageFilter;
beforeEach(() => {
stageFilter = sinon.spy();
});
test('renders RangeControl', () => {
const component = shallow(<RangeControl
control={control}
controlIndex={0}
stageFilter={stageFilter}
/>);
expect(component).toMatchSnapshot(); // eslint-disable-line
});

View file

@ -0,0 +1,128 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { RangeControl } from './range_control';
import { ListControl } from './list_control';
import { KuiFieldGroup, KuiFieldGroupSection, KuiButton } from 'ui_framework/components';
export class InputControlVis extends Component {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleReset = this.handleReset.bind(this);
this.handleClearAll = this.handleClearAll.bind(this);
}
handleSubmit() {
this.props.submitFilters();
}
handleReset() {
this.props.resetControls();
}
handleClearAll() {
this.props.clearControls();
}
renderControls() {
return this.props.controls.map((control, index) => {
let controlComponent = null;
switch (control.type) {
case 'list':
controlComponent = (
<ListControl
control={control}
controlIndex={index}
stageFilter={this.props.stageFilter}
/>
);
break;
case 'range':
controlComponent = (
<RangeControl
control={control}
controlIndex={index}
stageFilter={this.props.stageFilter}
/>
);
break;
default:
throw new Error(`Unhandled control type ${control.type}`);
}
return (
<div
key={control.id}
data-test-subj={'inputControl' + index}
>
{controlComponent}
</div>
);
});
}
renderStagingButtons() {
return (
<KuiFieldGroup className="actions">
<KuiFieldGroupSection>
<KuiButton
buttonType="primary"
type="button"
onClick={this.handleSubmit}
disabled={!this.props.hasChanges()}
data-test-subj="inputControlSubmitBtn"
>
Apply changes
</KuiButton>
</KuiFieldGroupSection>
<KuiFieldGroupSection>
<KuiButton
buttonType="basic"
type="button"
onClick={this.handleReset}
disabled={!this.props.hasChanges()}
data-test-subj="inputControlCancelBtn"
>
Cancel changes
</KuiButton>
</KuiFieldGroupSection>
<KuiFieldGroupSection>
<KuiButton
buttonType="basic"
type="button"
onClick={this.handleClearAll}
disabled={!this.props.hasValues()}
data-test-subj="inputControlClearBtn"
>
Clear form
</KuiButton>
</KuiFieldGroupSection>
</KuiFieldGroup>
);
}
render() {
let stagingButtons;
if (this.props.controls.length > 0 && !this.props.updateFiltersOnChange) {
stagingButtons = this.renderStagingButtons();
}
return (
<div className="inputControlVis">
{this.renderControls()}
{stagingButtons}
</div>
);
}
}
InputControlVis.propTypes = {
stageFilter: PropTypes.func.isRequired,
submitFilters: PropTypes.func.isRequired,
resetControls: PropTypes.func.isRequired,
clearControls: PropTypes.func.isRequired,
controls: PropTypes.array.isRequired,
updateFiltersOnChange: PropTypes.bool,
hasChanges: PropTypes.func.isRequired,
hasValues: PropTypes.func.isRequired
};

View file

@ -0,0 +1,183 @@
import React from 'react';
import sinon from 'sinon';
import { mount, shallow } from 'enzyme';
import {
InputControlVis,
} from './vis';
const mockListControl = {
id: 'mock-list-control',
options: {
type: 'terms',
multiselect: true
},
type: 'list',
label: 'list control',
value: '',
getMultiSelectDelimiter: () => { return ','; },
selectOptions: [
{ label: 'choice1', value: 'choice1' },
{ label: 'choice2', value: 'choice2' }
]
};
const mockRangeControl = {
id: 'mock-range-control',
options: {
decimalPlaces: 0,
step: 1
},
type: 'range',
label: 'ragne control',
value: { min: 0, max: 0 },
min: 0,
max: 100
};
const updateFiltersOnChange = false;
let stageFilter;
let submitFilters;
let resetControls;
let clearControls;
beforeEach(() => {
stageFilter = sinon.spy();
submitFilters = sinon.spy();
resetControls = sinon.spy();
clearControls = sinon.spy();
});
test('Renders list control', () => {
const component = shallow(<InputControlVis
stageFilter={stageFilter}
submitFilters={submitFilters}
resetControls={resetControls}
clearControls={clearControls}
controls={[mockListControl]}
updateFiltersOnChange={updateFiltersOnChange}
hasChanges={() => { return false; }}
hasValues={() => { return false; }}
/>);
expect(component).toMatchSnapshot(); // eslint-disable-line
});
test('Renders range control', () => {
const component = shallow(<InputControlVis
stageFilter={stageFilter}
submitFilters={submitFilters}
resetControls={resetControls}
clearControls={clearControls}
controls={[mockRangeControl]}
updateFiltersOnChange={updateFiltersOnChange}
hasChanges={() => { return false; }}
hasValues={() => { return false; }}
/>);
expect(component).toMatchSnapshot(); // eslint-disable-line
});
test('Apply and Cancel change btns enabled when there are changes', () => {
const component = shallow(<InputControlVis
stageFilter={stageFilter}
submitFilters={submitFilters}
resetControls={resetControls}
clearControls={clearControls}
controls={[mockListControl]}
updateFiltersOnChange={updateFiltersOnChange}
hasChanges={() => { return true; }}
hasValues={() => { return false; }}
/>);
expect(component).toMatchSnapshot(); // eslint-disable-line
});
test('Clear btns enabled when there are values', () => {
const component = shallow(<InputControlVis
stageFilter={stageFilter}
submitFilters={submitFilters}
resetControls={resetControls}
clearControls={clearControls}
controls={[mockListControl]}
updateFiltersOnChange={updateFiltersOnChange}
hasChanges={() => { return false; }}
hasValues={() => { return true; }}
/>);
expect(component).toMatchSnapshot(); // eslint-disable-line
});
test('clearControls', () => {
const component = mount(<InputControlVis
stageFilter={stageFilter}
submitFilters={submitFilters}
resetControls={resetControls}
clearControls={clearControls}
controls={[mockListControl]}
updateFiltersOnChange={updateFiltersOnChange}
hasChanges={() => { return true; }}
hasValues={() => { return true; }}
/>);
component.find('[data-test-subj="inputControlClearBtn"]').simulate('click');
sinon.assert.calledOnce(clearControls);
sinon.assert.notCalled(submitFilters);
sinon.assert.notCalled(resetControls);
sinon.assert.notCalled(stageFilter);
});
test('submitFilters', () => {
const component = mount(<InputControlVis
stageFilter={stageFilter}
submitFilters={submitFilters}
resetControls={resetControls}
clearControls={clearControls}
controls={[mockListControl]}
updateFiltersOnChange={updateFiltersOnChange}
hasChanges={() => { return true; }}
hasValues={() => { return true; }}
/>);
component.find('[data-test-subj="inputControlSubmitBtn"]').simulate('click');
sinon.assert.calledOnce(submitFilters);
sinon.assert.notCalled(clearControls);
sinon.assert.notCalled(resetControls);
sinon.assert.notCalled(stageFilter);
});
test('resetControls', () => {
const component = mount(<InputControlVis
stageFilter={stageFilter}
submitFilters={submitFilters}
resetControls={resetControls}
clearControls={clearControls}
controls={[mockListControl]}
updateFiltersOnChange={updateFiltersOnChange}
hasChanges={() => { return true; }}
hasValues={() => { return true; }}
/>);
component.find('[data-test-subj="inputControlCancelBtn"]').simulate('click');
sinon.assert.calledOnce(resetControls);
sinon.assert.notCalled(clearControls);
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}`);
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

@ -0,0 +1,52 @@
import _ from 'lodash';
export class Control {
constructor(controlParams, filterManager) {
this.id = controlParams.id;
this.options = controlParams.options;
this.type = controlParams.type;
this.label = controlParams.label ? controlParams.label : controlParams.fieldName;
this.filterManager = filterManager;
// restore state from kibana filter context
this.reset();
}
set(newValue) {
this.value = newValue;
this._hasChanged = true;
if (this.hasValue()) {
this._kbnFilter = this.filterManager.createFilter(this.value);
} else {
this._kbnFilter = null;
}
}
reset() {
this._hasChanged = false;
this._kbnFilter = null;
this.value = this.filterManager.getValueFromFilterBar();
}
clear() {
this.set(this.filterManager.getUnsetValue());
}
hasChanged() {
return this._hasChanged;
}
hasKbnFilter() {
if (this._kbnFilter) {
return true;
}
return false;
}
getKbnFilter() {
return this._kbnFilter;
}
hasValue() {
return !_.isEqual(this.value, this.filterManager.getUnsetValue());
}
}

View file

@ -0,0 +1,17 @@
import { rangeControlFactory } from './range_control_factory';
import { listControlFactory } from './list_control_factory';
export function controlFactory(controlParams) {
let factory = null;
switch (controlParams.type) {
case 'range':
factory = rangeControlFactory;
break;
case 'list':
factory = listControlFactory;
break;
default:
throw new Error(`Unhandled control type ${controlParams.type}`);
}
return factory;
}

View file

@ -0,0 +1,190 @@
import expect from 'expect.js';
import { PhraseFilterManager } from '../phrase_filter_manager';
describe('PhraseFilterManager', function () {
describe('createFilter', function () {
const indexPatternId = '1';
const fieldMock = {
name: 'field1',
format: {
convert: (val) => { return val; }
}
};
const indexPatternMock = {
id: indexPatternId,
fields: {
byName: {
field1: fieldMock
}
}
};
const queryFilterMock = {};
let filterManager;
beforeEach(() => {
filterManager = new PhraseFilterManager('field1', indexPatternMock, queryFilterMock, '|');
});
it('should create match phrase filter from single value', function () {
const newFilter = filterManager.createFilter('ios');
expect(newFilter).to.have.property('meta');
expect(newFilter.meta.index).to.be(indexPatternId);
expect(newFilter).to.have.property('query');
expect(JSON.stringify(newFilter.query, null, '')).to.be('{"match":{"field1":{"query":"ios","type":"phrase"}}}');
});
it('should create bool filter from multiple values', function () {
const newFilter = filterManager.createFilter('ios|win xp');
expect(newFilter).to.have.property('meta');
expect(newFilter.meta.index).to.be(indexPatternId);
expect(newFilter).to.have.property('query');
const query = newFilter.query;
expect(query).to.have.property('bool');
expect(query.bool.should.length).to.be(2);
expect(JSON.stringify(query.bool.should[0], null, '')).to.be('{"match_phrase":{"field1":"ios"}}');
expect(JSON.stringify(query.bool.should[1], null, '')).to.be('{"match_phrase":{"field1":"win xp"}}');
});
});
describe('findFilters', function () {
const indexPatternMock = {};
let kbnFilters;
const queryFilterMock = {
getAppFilters: () => { return kbnFilters; },
getGlobalFilters: () => { return []; }
};
let filterManager;
beforeEach(() => {
kbnFilters = [];
filterManager = new PhraseFilterManager('field1', indexPatternMock, queryFilterMock, '|');
});
it('should not find phrase filters for other fields', function () {
kbnFilters.push({
query: {
match: {
notField1: {
query: 'ios',
type: 'phrase'
}
}
}
});
const foundFilters = filterManager.findFilters();
expect(foundFilters.length).to.be(0);
});
it('should find phrase filters for target fields', function () {
kbnFilters.push({
query: {
match: {
field1: {
query: 'ios',
type: 'phrase'
}
}
}
});
const foundFilters = filterManager.findFilters();
expect(foundFilters.length).to.be(1);
});
it('should not find bool filters for other fields', function () {
kbnFilters.push({
query: {
bool: {
should: [
{
match_phrase: {
notField1: 'ios'
}
}
]
}
}
});
const foundFilters = filterManager.findFilters();
expect(foundFilters.length).to.be(0);
});
it('should find bool filters for target field', function () {
kbnFilters.push({
query: {
bool: {
should: [
{
match_phrase: {
field1: 'ios'
}
}
]
}
}
});
const foundFilters = filterManager.findFilters();
expect(foundFilters.length).to.be(1);
});
});
describe('getValueFromFilterBar', function () {
const indexPatternMock = {};
const queryFilterMock = {};
let filterManager;
beforeEach(() => {
class MockFindFiltersPhraseFilterManager extends PhraseFilterManager {
constructor(fieldName, indexPattern, queryFilter, delimiter) {
super(fieldName, indexPattern, queryFilter, delimiter);
this.mockFilters = [];
}
findFilters() {
return this.mockFilters;
}
setMockFilters(mockFilters) {
this.mockFilters = mockFilters;
}
}
filterManager = new MockFindFiltersPhraseFilterManager('field1', indexPatternMock, queryFilterMock, '|');
});
it('should extract value from match phrase filter', function () {
filterManager.setMockFilters([
{
query: {
match: {
field1: {
query: 'ios',
type: 'phrase'
}
}
}
}
]);
expect(filterManager.getValueFromFilterBar()).to.be('ios');
});
it('should extract value from bool filter', function () {
filterManager.setMockFilters([
{
query: {
bool: {
should: [
{
match_phrase: {
field1: 'ios'
}
},
{
match_phrase: {
field1: 'win xp'
}
}
]
}
}
}
]);
expect(filterManager.getValueFromFilterBar()).to.be('ios|win xp');
});
});
});

View file

@ -0,0 +1,115 @@
import expect from 'expect.js';
import { RangeFilterManager } from '../range_filter_manager';
describe('RangeFilterManager', function () {
describe('createFilter', function () {
const indexPatternId = '1';
const fieldMock = {
name: 'field1'
};
const indexPatternMock = {
id: indexPatternId,
fields: {
byName: {
field1: fieldMock
}
}
};
const queryFilterMock = {};
let filterManager;
beforeEach(() => {
filterManager = new RangeFilterManager('field1', indexPatternMock, queryFilterMock);
});
it('should create range filter from slider value', function () {
const newFilter = filterManager.createFilter({ min: 1, max: 3 });
expect(newFilter).to.have.property('meta');
expect(newFilter.meta.index).to.be(indexPatternId);
expect(newFilter).to.have.property('range');
expect(JSON.stringify(newFilter.range, null, '')).to.be('{"field1":{"gte":1,"lt":3}}');
});
});
describe('findFilters', function () {
const indexPatternMock = {};
let kbnFilters;
const queryFilterMock = {
getAppFilters: () => { return kbnFilters; },
getGlobalFilters: () => { return []; }
};
let filterManager;
beforeEach(() => {
kbnFilters = [];
filterManager = new RangeFilterManager('field1', indexPatternMock, queryFilterMock);
});
it('should not find range filters for other fields', function () {
kbnFilters.push({
range: {
notField1: {
gte: 1,
lt: 3
}
}
});
const foundFilters = filterManager.findFilters();
expect(foundFilters.length).to.be(0);
});
it('should find range filters for target fields', function () {
kbnFilters.push({
range: {
field1: {
gte: 1,
lt: 3
}
}
});
const foundFilters = filterManager.findFilters();
expect(foundFilters.length).to.be(1);
});
});
describe('getValueFromFilterBar', function () {
const indexPatternMock = {};
const queryFilterMock = {};
let filterManager;
beforeEach(() => {
class MockFindFiltersRangeFilterManager extends RangeFilterManager {
constructor(fieldName, indexPattern, queryFilter) {
super(fieldName, indexPattern, queryFilter);
this.mockFilters = [];
}
findFilters() {
return this.mockFilters;
}
setMockFilters(mockFilters) {
this.mockFilters = mockFilters;
}
}
filterManager = new MockFindFiltersRangeFilterManager('field1', indexPatternMock, queryFilterMock);
});
it('should extract value from range filter', function () {
filterManager.setMockFilters([
{
range: {
field1: {
gte: 1,
lt: 3
}
}
}
]);
const value = filterManager.getValueFromFilterBar();
expect(value).to.be.a('object');
expect(value).to.have.property('min');
expect(value.min).to.be(1);
expect(value).to.have.property('max');
expect(value.max).to.be(3);
});
});
});

View file

@ -0,0 +1,25 @@
export class FilterManager {
constructor(fieldName, indexPattern, queryFilter, unsetValue) {
this.fieldName = fieldName;
this.indexPattern = indexPattern;
this.queryFilter = queryFilter;
this.unsetValue = unsetValue;
}
createFilter() {
throw new Error('Must implement createFilter.');
}
findFilters() {
throw new Error('Must implement findFilters.');
}
getValueFromFilterBar() {
throw new Error('Must implement getValueFromFilterBar.');
}
getUnsetValue() {
return this.unsetValue;
}
}

View file

@ -0,0 +1,123 @@
import _ from 'lodash';
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(fieldName, indexPattern, queryFilter, delimiter) {
super(fieldName, indexPattern, queryFilter, EMPTY_VALUE);
this.delimiter = delimiter;
}
/**
* Convert phrases into filter
*
* @param {string} react-select value (delimiter-separated string of values)
* @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);
if (phrases.length === 1) {
return buildPhraseFilter(
this.indexPattern.fields.byName[this.fieldName],
phrases[0],
this.indexPattern);
} else {
return buildPhrasesFilter(
this.indexPattern.fields.byName[this.fieldName],
phrases,
this.indexPattern);
}
}
findFilters() {
const kbnFilters = _.flatten([this.queryFilter.getAppFilters(), this.queryFilter.getGlobalFilters()]);
return kbnFilters.filter((kbnFilter) => {
return this._findFilter(kbnFilter);
});
}
_findFilter(kbnFilter) {
// bool filter - multiple phrase filters
if (_.has(kbnFilter, 'query.bool.should')) {
const subFilters = _.get(kbnFilter, 'query.bool.should')
.map((kbnFilter) => {
return this._findFilter(kbnFilter);
});
return subFilters.reduce((a, b) => {
return a || b;
});
}
// scripted field filter
if (_.has(kbnFilter, 'script')
&& _.get(kbnFilter, 'meta.index') === this.indexPattern.id
&& _.get(kbnFilter, 'meta.field') === this.fieldName) {
return true;
}
// single phrase filter
if (_.has(kbnFilter, ['query', 'match', this.fieldName])) {
return true;
}
// single phrase filter from bool filter
if (_.has(kbnFilter, ['match_phrase', this.fieldName])) {
return true;
}
return false;
}
getValueFromFilterBar() {
const kbnFilters = this.findFilters();
if (kbnFilters.length === 0) {
return this.getUnsetValue();
} else {
const values = kbnFilters
.map((kbnFilter) => {
return this._getValueFromFilter(kbnFilter);
});
return values.join(this.delimiter);
}
}
_getValueFromFilter(kbnFilter) {
// bool filter - multiple phrase filters
if (_.has(kbnFilter, 'query.bool.should')) {
return _.get(kbnFilter, 'query.bool.should')
.map((kbnFilter) => {
return this._getValueFromFilter(kbnFilter);
})
.filter((value) => {
if (value) {
return true;
}
return false;
})
.join(this.delimiter);
}
// scripted field filter
if (_.has(kbnFilter, 'script')) {
return _.get(kbnFilter, 'script.script.params.value', this.getUnsetValue());
}
// single phrase filter
if (_.has(kbnFilter, ['query', 'match', this.fieldName])) {
return _.get(kbnFilter, ['query', 'match', this.fieldName, 'query'], this.getUnsetValue());
}
// single phrase filter from bool filter
if (_.has(kbnFilter, ['match_phrase', this.fieldName])) {
return _.get(kbnFilter, ['match_phrase', this.fieldName], this.getUnsetValue());
}
return this.getUnsetValue();
}
}

View file

@ -0,0 +1,76 @@
import _ from 'lodash';
import { FilterManager } from './filter_manager.js';
import { buildRangeFilter } from 'ui/filter_manager/lib/range';
// Convert slider value into ES range filter
function toRange(sliderValue) {
return {
gte: sliderValue.min,
lt: sliderValue.max
};
}
// Convert ES range filter into slider value
function fromRange(range) {
const sliderValue = {};
if (_.has(range, 'gte')) {
sliderValue.min = _.get(range, 'gte');
}
if (_.has(range, 'gt')) {
sliderValue.min = _.get(range, 'gt');
}
if (_.has(range, 'lte')) {
sliderValue.max = _.get(range, 'lte');
}
if (_.has(range, 'lt')) {
sliderValue.max = _.get(range, 'lt');
}
return sliderValue;
}
export class RangeFilterManager extends FilterManager {
/**
* Convert slider value into filter
*
* @param {object} react-input-range value - POJO with `min` and `max` properties
* @return {object} range filter
*/
createFilter(value) {
return buildRangeFilter(
this.indexPattern.fields.byName[this.fieldName],
toRange(value),
this.indexPattern);
}
findFilters() {
const kbnFilters = _.flatten([this.queryFilter.getAppFilters(), this.queryFilter.getGlobalFilters()]);
return kbnFilters.filter((kbnFilter) => {
if (_.has(kbnFilter, 'script')
&& _.get(kbnFilter, 'meta.index') === this.indexPattern.id
&& _.get(kbnFilter, 'meta.field') === this.fieldName) {
//filter is a scripted filter for this index/field
return true;
} else if (_.has(kbnFilter, ['range', this.fieldName]) && _.get(kbnFilter, 'meta.index') === this.indexPattern.id) {
//filter is a match filter for this index/field
return true;
}
return false;
});
}
getValueFromFilterBar() {
const kbnFilters = this.findFilters();
if (kbnFilters.length === 0) {
return this.getUnsetValue();
} else {
let range = null;
if (_.has(kbnFilters[0], 'script')) {
range = _.get(kbnFilters[0], 'script.script.params');
} else {
range = _.get(kbnFilters[0], ['range', this.fieldName]);
}
return fromRange(range);
}
}
}

View file

@ -0,0 +1,65 @@
import _ from 'lodash';
import { Control } from './control';
import { PhraseFilterManager } from './filter_manager/phrase_filter_manager';
const termsAgg = (field, size, direction) => {
if (size < 1) {
size = 1;
}
const terms = {
'size': size,
'order': {
'_count': direction
}
};
if (field.scripted) {
terms.script = {
inline: field.script,
lang: field.lang
};
terms.valueType = field.type === 'number' ? 'float' : field.type;
} else {
terms.field = field.name;
}
return {
'termsAgg': {
'terms': terms
}
};
};
const listControlDelimiter = '$$kbn_delimiter$$';
class ListControl extends Control {
constructor(controlParams, filterManager, selectOptions) {
super(controlParams, filterManager);
this.selectOptions = selectOptions;
}
getMultiSelectDelimiter() {
return this.filterManager.delimiter;
}
}
export async function listControlFactory(controlParams, kbnApi) {
const indexPattern = await kbnApi.indexPatterns.get(controlParams.indexPattern);
const searchSource = new kbnApi.SearchSource();
searchSource.inherits(false); //Do not filter by time so can not inherit from rootSearchSource
searchSource.size(0);
searchSource.index(indexPattern);
searchSource.aggs(termsAgg(
indexPattern.fields.byName[controlParams.fieldName],
_.get(controlParams, 'options.size', 5),
'desc'));
const resp = await searchSource.fetch();
return new ListControl(
controlParams,
new PhraseFilterManager(controlParams.fieldName, indexPattern, kbnApi.queryFilter, listControlDelimiter),
_.get(resp, 'aggregations.termsAgg.buckets', []).map((bucket) => {
return { label: bucket.key.toString(), value: bucket.key.toString() };
})
);
}

View file

@ -0,0 +1,52 @@
import _ from 'lodash';
import { Control } from './control';
import { RangeFilterManager } from './filter_manager/range_filter_manager';
const minMaxAgg = (field) => {
const aggBody = {};
if (field.scripted) {
aggBody.script = {
inline: field.script,
lang: field.lang
};
} else {
aggBody.field = field.name;
}
return {
maxAgg: {
max: aggBody
},
minAgg: {
min: aggBody
}
};
};
class RangeControl extends Control {
constructor(controlParams, filterManager, min, max) {
super(controlParams, filterManager);
this.min = min;
this.max = max;
}
}
export async function rangeControlFactory(controlParams, kbnApi) {
const indexPattern = await kbnApi.indexPatterns.get(controlParams.indexPattern);
const searchSource = new kbnApi.SearchSource();
searchSource.inherits(false); //Do not filter by time so can not inherit from rootSearchSource
searchSource.size(0);
searchSource.index(indexPattern);
searchSource.aggs(minMaxAgg(indexPattern.fields.byName[controlParams.fieldName]));
const resp = await searchSource.fetch();
const min = _.get(resp, 'aggregations.minAgg.value');
const max = _.get(resp, 'aggregations.maxAgg.value');
const emptyValue = { min: min, max: min };
return new RangeControl(
controlParams,
new RangeFilterManager(controlParams.fieldName, indexPattern, kbnApi.queryFilter, emptyValue),
min,
max
);
}

View file

@ -0,0 +1,78 @@
export const setControl = (controls, controlIndex, control) => [
...controls.slice(0, controlIndex),
control,
...controls.slice(controlIndex + 1)
];
export const addControl = (controls, control) => [...controls, control];
export const moveControl = (controls, controlIndex, direction) => {
let newIndex;
if (direction >= 0) {
newIndex = controlIndex + 1;
} else {
newIndex = controlIndex - 1;
}
if (newIndex < 0) {
// Move first item to last
return [
...controls.slice(1),
controls[0]
];
} else if (newIndex >= controls.length) {
const lastItemIndex = controls.length - 1;
// Move last item to first
return [
controls[lastItemIndex],
...controls.slice(0, lastItemIndex)
];
} else {
const swapped = controls.slice();
const temp = swapped[newIndex];
swapped[newIndex] = swapped[controlIndex];
swapped[controlIndex] = temp;
return swapped;
}
};
export const removeControl = (controls, controlIndex) => [
...controls.slice(0, controlIndex),
...controls.slice(controlIndex + 1)
];
export const getDefaultOptions = (type) => {
const defaultOptions = {};
switch (type) {
case 'range':
defaultOptions.decimalPlaces = 0;
defaultOptions.step = 1;
break;
case 'list':
defaultOptions.type = 'terms';
defaultOptions.multiselect = true;
defaultOptions.size = 5;
defaultOptions.order = 'desc';
break;
}
return defaultOptions;
};
export const newControl = (type) => ({
id: (new Date()).getTime().toString(),
indexPattern: '',
fieldName: '',
label: '',
type: type,
options: getDefaultOptions(type),
});
export const getTitle = (controlParams, controlIndex) => {
let title = `${controlParams.type}: ${controlIndex}`;
if (controlParams.label) {
title = `${controlParams.type}: ${controlParams.label}`;
} else if (controlParams.fieldName) {
title = `${controlParams.type}: ${controlParams.fieldName}`;
}
return title;
};

View file

@ -0,0 +1,52 @@
import './vis.less';
import { CATEGORY } from 'ui/vis/vis_category';
import { VisFactoryProvider } from 'ui/vis/vis_factory';
import { VisTypesRegistryProvider } from 'ui/registry/vis_types';
import { VisController } from './vis_controller';
import { ControlsTab } from './components/editor/controls_tab';
import { OptionsTab } from './components/editor/options_tab';
function InputControlVisProvider(Private) {
const VisFactory = Private(VisFactoryProvider);
// return the visType object, which kibana will use to display and configure new Vis object of this type.
return VisFactory.createBaseVisualization({
name: 'input_control_vis',
title: 'Controls',
icon: 'fa fa-gear',
description: 'Create interactive controls for easy dashboard manipulation.',
category: CATEGORY.OTHER,
isExperimental: true,
visualization: VisController,
visConfig: {
defaults: {
controls: [],
updateFiltersOnChange: false
},
},
editor: 'default',
editorConfig: {
optionTabs: [
{
name: 'controls',
title: 'Controls',
editor: ControlsTab
},
{
name: 'options',
title: 'Options',
editor: OptionsTab
}
]
},
requestHandler: 'none',
responseHandler: 'none',
});
}
// register the provider with the visTypes registry
VisTypesRegistryProvider.register(InputControlVisProvider);
// export the provider so that the visType can be required with Private()
export default InputControlVisProvider;

View file

@ -0,0 +1,48 @@
@import (reference) "~ui/styles/mixins.less";
.inputControlVis {
width: 100%;
margin: 0 5px;
.inputRangeContainer {
display: inline-block;
width: 70%;
}
input.kuiTextInput {
width: 15%;
}
input[type=number]::-webkit-inner-spin-button,
input[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
// hide slider labels since they are displayed in inputs
.input-range__track {
.input-range__label-container {
display: none;
}
}
// do not center min/max labels - otherwise the overlfow slider sides
.input-range__label-container {
left: 0% !important;
}
.actions {
margin-top: 5px;
}
}
visualization.input_control_vis {
overflow: visible;
.vis-container {
overflow: visible;
}
}

View file

@ -0,0 +1,126 @@
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { InputControlVis } from './components/vis/vis';
import { controlFactory } from './control/control_factory';
class VisController {
constructor(el, vis) {
this.el = el;
this.vis = vis;
this.controls = [];
this.queryBarUpdateHandler = this.updateControlsFromKbn.bind(this);
this.vis.API.queryFilter.on('update', this.queryBarUpdateHandler);
}
async render(visData, status) {
if (status.params) {
this.controls = [];
this.controls = await this.initControls();
this.drawVis();
return;
}
return;
}
destroy() {
this.vis.API.queryFilter.off('update', this.queryBarUpdateHandler);
unmountComponentAtNode(this.el);
}
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)}
/>,
this.el);
}
async initControls() {
return await Promise.all(
this.vis.params.controls.filter((controlParams) => {
// ignore controls that do not have indexPattern or field
return controlParams.indexPattern && controlParams.fieldName;
})
.map((controlParams) => {
const factory = controlFactory(controlParams);
return factory(controlParams, this.vis.API);
})
);
}
stageFilter(controlIndex, newValue) {
this.controls[controlIndex].set(newValue);
if (this.vis.params.updateFiltersOnChange) {
// submit filters on each control change
this.submitFilters();
} else {
// Do not submit filters, just update vis so controls are updated with latest value
this.drawVis();
}
}
submitFilters() {
const stagedControls = this.controls.filter((control) => {
return control.hasChanged();
});
const newFilters = stagedControls
.filter((control) => {
return control.hasKbnFilter();
})
.map((control) => {
return control.getKbnFilter();
});
stagedControls.forEach((control) => {
// to avoid duplicate filters, remove any old filters for control
control.filterManager.findFilters().forEach((existingFilter) => {
this.vis.API.queryFilter.removeFilter(existingFilter);
});
});
this.vis.API.queryFilter.addFilters(newFilters);
}
clearControls() {
this.controls.forEach((control) => {
control.clear();
});
this.drawVis();
}
updateControlsFromKbn() {
this.controls.forEach((control) => {
control.reset();
});
this.drawVis();
}
hasChanges() {
return this.controls.map((control) => {
return control.hasChanged();
})
.reduce((a, b) => {
return a || b;
});
}
hasValues() {
return this.controls.map((control) => {
return control.hasValue();
})
.reduce((a, b) => {
return a || b;
});
}
}
export { VisController };

View file

@ -112,6 +112,10 @@ dashboard-grid {
i.remove {
cursor: pointer;
}
.gs-w {
z-index: auto;
}
}
.panel {
@ -134,6 +138,7 @@ dashboard-grid {
/**
* 1. Fix Firefox bug where a value of overflow: hidden will prevent scrolling in a panel where the spy panel does
* not have enough room.
* 2. react-select used in input control vis needs `visible` overflow to avoid clipping selection list
*/
dashboard-panel {
flex: 1;
@ -144,7 +149,8 @@ dashboard-panel {
background: @dashboard-panel-bg;
color: @dashboard-panel-color;
padding: 0;
overflow: auto; /* 1 */
overflow: visible; /* 1, 2 */
position: relative;
.panel {

View file

@ -1,5 +1,4 @@
import '../visualizations/less/main.less';
import 'react-select/dist/react-select.css';
import '../less/main.less';
import image from '../images/icon-visualbuilder.svg';
import { MetricsRequestHandlerProvider } from './request_handler';

View file

@ -1,7 +1,7 @@
{
"rootDir": "../../",
"roots": [
"<rootDir>/src/core_plugins/kibana/public/dashboard",
"<rootDir>/src/core_plugins",
"<rootDir>/ui_framework/"
],
"collectCoverageFrom": [

View file

@ -2,6 +2,8 @@
@import (reference) "./mixins";
@import (reference) "./variables";
@import (reference) "~ui/styles/bootstrap/bootstrap";
@import "./react-input-range";
@import "./react-select";
html,
body {

View file

@ -587,6 +587,103 @@
}
}
// react-select
.theme-dark {
.Select-control {
background-color: #444444;
border: 1px solid #444444;
color: #cecece;
}
.is-open > .Select-control {
background: #444444;
border: 1px solid #444444;
}
.is-focused:not(.is-open) > .Select-control {
border-color: #0079a5;
box-shadow: none;
}
.Select-menu-outer {
border: 1px solid #444444;
border-top-color: black;
}
.Select-option {
background-color: #444444;
color: #cecece;
}
.Select--multi .Select-value {
color: #b7e2ea;
border: 1px solid #b7e2ea;
}
.Select--multi .Select-value-icon {
border-right: 1px solid #b7e2ea;
}
.Select-option.is-focused {
background-color: #9c9c9c;
color: #fff;
}
.Select--multi .Select-value-icon:hover,
.Select--multi .Select-value-icon:focus {
color: #444444;
background-color: #b7e2ea;
}
.has-value.Select--single > .Select-control .Select-value .Select-value-label,
.has-value.is-pseudo-focused.Select--single > .Select-control .Select-value .Select-value-label {
color: #cecece;
}
.Select-clear-zone {
color: #a6a6a6;
}
.Select-clear-zone:hover {
color: black;
}
.Select-arrow {
border-color: #a6a6a6 transparent transparent;
}
.is-open > .Select-control .Select-arrow {
border-color: transparent transparent black;
}
.is-open .Select-arrow,
.Select-arrow-zone:hover > .Select-arrow {
border-top-color: black;
}
}
// react-input-range
.theme-dark {
.input-range__track {
background-color: #cecece;
}
.input-range__slider {
background: #444444;
border: 1px solid #444444;
}
.input-range__track--active {
background: #444444;
}
.input-range__label {
color: #cecece;
}
}
.theme-dark {
.markdown-body {
color: @text-color;

View file

@ -0,0 +1,84 @@
.input-range__slider {
appearance: none;
background: #0079a5;
border: 1px solid #0079a5;
border-radius: 100%;
cursor: pointer;
display: block;
height: 1rem;
margin-left: -0.5rem;
margin-top: -0.65rem;
outline: none;
position: absolute;
top: 50%;
transition: transform 0.3s ease-out, box-shadow 0.3s ease-out;
width: 1rem; }
.input-range__slider:active {
transform: scale(1.3); }
.input-range__slider:focus {
box-shadow: 0 0 0 5px rgba(63, 81, 181, 0.2); }
.input-range--disabled .input-range__slider {
background: #cccccc;
border: 1px solid #cccccc;
box-shadow: none;
transform: none; }
.input-range__slider-container {
transition: left 0.3s ease-out; }
.input-range__label {
color: #aaaaaa;
font-family: "Helvetica Neue", san-serif;
font-size: 0.8rem;
transform: translateZ(0);
white-space: nowrap; }
.input-range__label--min,
.input-range__label--max {
bottom: -1.4rem;
position: absolute; }
.input-range__label--min {
left: 0; }
.input-range__label--max {
right: 0; }
.input-range__label--value {
position: absolute;
top: -1.8rem; }
.input-range__label-container {
left: -50%;
position: relative; }
.input-range__label--max .input-range__label-container {
left: 50%; }
.input-range__track {
background: #eeeeee;
border-radius: 0.3rem;
cursor: pointer;
display: block;
height: 0.3rem;
position: relative;
transition: left 0.3s ease-out, width 0.3s ease-out; }
.input-range--disabled .input-range__track {
background: #eeeeee; }
.input-range__track--background {
left: 0;
margin-top: -0.15rem;
position: absolute;
right: 0;
top: 50%; }
.input-range__track--active {
background: #0079a5; }
.input-range {
margin: 0px 10px;
position: relative;
width: calc(~"100% - 20px");
}
/*# sourceMappingURL=index.css.map */

View file

@ -0,0 +1,349 @@
.Select {
position: relative;
}
.Select,
.Select div,
.Select input,
.Select span {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
.Select.is-disabled > .Select-control {
background-color: #f9f9f9;
}
.Select.is-disabled .Select-arrow-zone {
cursor: default;
pointer-events: none;
opacity: 0.35;
}
.Select-control {
background-color: #fff;
border-radius: 4px;
border: 1px solid #DEDEDE;
color: #191E23;
cursor: default;
display: table;
border-spacing: 0;
border-collapse: separate;
height: 36px;
outline: none;
overflow: hidden;
position: relative;
width: 100%;
font-size: 14px;
}
.Select-control .Select-input:focus {
outline: none;
}
.is-searchable.is-open > .Select-control {
cursor: text;
}
.is-open > .Select-control {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
background: #fff;
border-color: #DEDEDE;
}
.is-open > .Select-control .Select-arrow {
top: -2px;
border-color: transparent transparent #191E23;
border-width: 0 5px 5px;
}
.is-searchable.is-focused:not(.is-open) > .Select-control {
cursor: text;
}
.is-focused:not(.is-open) > .Select-control {
border-color: #0079a5;
}
.Select-placeholder,
.Select--single > .Select-control .Select-value {
bottom: 0;
color: #757575;
left: 0;
line-height: 34px;
padding-left: 10px;
padding-right: 10px;
position: absolute;
right: 0;
top: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.has-value.Select--single > .Select-control .Select-value .Select-value-label,
.has-value.is-pseudo-focused.Select--single > .Select-control .Select-value .Select-value-label {
color: #333;
}
.has-value.Select--single > .Select-control .Select-value a.Select-value-label,
.has-value.is-pseudo-focused.Select--single > .Select-control .Select-value a.Select-value-label {
cursor: pointer;
text-decoration: none;
}
.has-value.Select--single > .Select-control .Select-value a.Select-value-label:hover,
.has-value.is-pseudo-focused.Select--single > .Select-control .Select-value a.Select-value-label:hover,
.has-value.Select--single > .Select-control .Select-value a.Select-value-label:focus,
.has-value.is-pseudo-focused.Select--single > .Select-control .Select-value a.Select-value-label:focus {
color: #007eff;
outline: none;
text-decoration: underline;
}
.Select-input {
height: 34px;
padding-left: 10px;
padding-right: 10px;
vertical-align: middle;
}
.Select-input > input {
width: 100%;
background: none transparent;
border: 0 none;
box-shadow: none;
cursor: default;
display: inline-block;
font-family: inherit;
font-size: inherit;
margin: 0;
outline: none;
line-height: 14px;
/* For IE 8 compatibility */
padding: 8px 0 12px;
/* For IE 8 compatibility */
-webkit-appearance: none;
}
.is-focused .Select-input > input {
cursor: text;
}
.has-value.is-pseudo-focused .Select-input {
opacity: 0;
}
.Select-control:not(.is-searchable) > .Select-input {
outline: none;
}
.Select-loading-zone {
cursor: pointer;
display: table-cell;
position: relative;
text-align: center;
vertical-align: middle;
width: 16px;
}
.Select-loading {
-webkit-animation: Select-animation-spin 400ms infinite linear;
-o-animation: Select-animation-spin 400ms infinite linear;
animation: Select-animation-spin 400ms infinite linear;
width: 16px;
height: 16px;
box-sizing: border-box;
border-radius: 50%;
border: 2px solid #DEDEDE;
display: inline-block;
position: relative;
vertical-align: middle;
}
.Select-clear-zone {
-webkit-animation: Select-animation-fadeIn 200ms;
-o-animation: Select-animation-fadeIn 200ms;
animation: Select-animation-fadeIn 200ms;
color: #DEDEDE;
cursor: pointer;
display: table-cell;
position: relative;
text-align: center;
vertical-align: middle;
width: 17px;
}
.Select-clear-zone:hover {
color: #191E23;
}
.Select-clear {
display: inline-block;
font-size: 18px;
line-height: 1;
}
.Select--multi .Select-clear-zone {
width: 17px;
}
.Select-arrow-zone {
cursor: pointer;
display: table-cell;
position: relative;
text-align: center;
vertical-align: middle;
width: 25px;
padding-right: 5px;
}
.Select-arrow {
border-color: #DEDEDE transparent transparent;
border-style: solid;
border-width: 5px 5px 2.5px;
display: inline-block;
height: 0;
width: 0;
position: relative;
}
.is-open .Select-arrow,
.Select-arrow-zone:hover > .Select-arrow {
border-top-color: #191E23;
}
.Select--multi .Select-multi-value-wrapper {
display: inline-block;
}
.Select .Select-aria-only {
display: inline-block;
height: 1px;
width: 1px;
margin: -1px;
clip: rect(0, 0, 0, 0);
overflow: hidden;
float: left;
}
@-webkit-keyframes Select-animation-fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes Select-animation-fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.Select-menu-outer {
border-bottom-right-radius: 4px;
border-bottom-left-radius: 4px;
background-color: #fff;
border: 1px solid #DEDEDE;
border-top-color: #DEDEDE;
box-sizing: border-box;
margin-top: -1px;
max-height: 200px;
position: absolute;
top: 100%;
width: 100%;
z-index: 99999;
-webkit-overflow-scrolling: touch;
}
.Select-menu {
max-height: 198px;
overflow-y: auto;
}
.Select-option {
box-sizing: border-box;
background-color: #fff;
color: #666666;
cursor: pointer;
display: block;
padding: 8px 10px;
}
.Select-option:last-child {
border-bottom-right-radius: 4px;
border-bottom-left-radius: 4px;
}
.Select-option.is-selected {
background-color: #f5faff;
/* Fallback color for IE 8 */
background-color: rgba(0, 126, 255, 0.04);
color: #333;
}
.Select-option.is-focused {
background-color: #ebf5ff;
/* Fallback color for IE 8 */
background-color: rgba(0, 126, 255, 0.08);
color: #333;
}
.Select-option.is-disabled {
color: #cccccc;
cursor: default;
}
.Select-noresults {
box-sizing: border-box;
color: #999999;
cursor: default;
display: block;
padding: 8px 10px;
}
.Select--multi .Select-input {
vertical-align: middle;
margin-left: 10px;
padding: 0;
}
.Select--multi.has-value .Select-input {
margin-left: 5px;
}
.Select--multi .Select-value {
background-color: inherit;
border-radius: 2px;
border: 1px solid #0079a5;
color: #0079a5;
display: inline-block;
font-size: 0.9em;
line-height: 1.4;
margin-left: 5px;
margin-top: 5px;
vertical-align: top;
}
.Select--multi .Select-value-icon,
.Select--multi .Select-value-label {
display: inline-block;
vertical-align: middle;
}
.Select--multi .Select-value-label {
border-bottom-right-radius: 2px;
border-top-right-radius: 2px;
cursor: default;
padding: 2px 5px;
}
.Select--multi a.Select-value-label {
color: #0079a5;
cursor: pointer;
text-decoration: none;
}
.Select--multi a.Select-value-label:hover {
text-decoration: underline;
}
.Select--multi .Select-value-icon {
cursor: pointer;
border-bottom-left-radius: 2px;
border-top-left-radius: 2px;
border-right: 1px solid #0079a5;
padding: 1px 5px 3px;
}
.Select--multi .Select-value-icon:hover,
.Select--multi .Select-value-icon:focus {
background-color: #0079a5;
color: #fff;
}
.Select--multi .Select-value-icon:active {
background-color: #c2e0ff;
}
.Select--multi.is-disabled .Select-value {
background-color: #fcfcfc;
border: 1px solid #e3e3e3;
color: #333;
}
.Select--multi.is-disabled .Select-value-icon {
cursor: not-allowed;
border-right: 1px solid #e3e3e3;
}
.Select--multi.is-disabled .Select-value-icon:hover,
.Select--multi.is-disabled .Select-value-icon:focus,
.Select--multi.is-disabled .Select-value-icon:active {
background-color: #fcfcfc;
}
@keyframes Select-animation-spin {
to {
transform: rotate(1turn);
}
}
@-webkit-keyframes Select-animation-spin {
to {
-webkit-transform: rotate(1turn);
}
}

View file

@ -40,6 +40,7 @@
ng-class="{'is-vis-editor-sub-nav-link-selected': sidebar.section == tab.name}"
ng-click="sidebar.section=tab.name"
kbn-accessible-click
data-test-subj="{{ 'visEditorTab' + tab.name }}"
>
{{tab.title}}
</a>

View file

@ -19,6 +19,8 @@ import { FilterBarClickHandlerProvider } from 'ui/filter_bar/filter_bar_click_ha
import { updateVisualizationConfig } from './vis_update';
import { queryManagerFactory } from '../query_manager';
import * as kueryAPI from 'ui/kuery';
import { SearchSourceProvider } from 'ui/courier/data_source/search_source';
import { SavedObjectsClientProvider } from 'ui/saved_objects';
export function VisProvider(Private, indexPatterns, timefilter, getAppState) {
const visTypes = Private(VisTypesRegistryProvider);
@ -26,6 +28,8 @@ export function VisProvider(Private, indexPatterns, timefilter, getAppState) {
const brushEvent = Private(UtilsBrushEventProvider);
const queryFilter = Private(FilterBarQueryFilterProvider);
const filterBarClickHandler = Private(FilterBarClickHandlerProvider);
const SearchSource = Private(SearchSourceProvider);
const savedObjectsClient = Private(SavedObjectsClientProvider);
class Vis extends EventEmitter {
constructor(indexPattern, visState, uiState) {
@ -52,6 +56,8 @@ export function VisProvider(Private, indexPatterns, timefilter, getAppState) {
this.sessionState = {};
this.API = {
savedObjectsClient: savedObjectsClient,
SearchSource: SearchSource,
indexPatterns: indexPatterns,
timeFilter: timefilter,
queryFilter: queryFilter,

View file

@ -10,6 +10,7 @@
/>
<visualization
ng-if="editorMode==false"
class={{vis.type.name}}
vis="vis"
vis-data="visData"
ui-state="uiState"

View file

@ -30,6 +30,7 @@ export default function ({ getService, getPageObjects }) {
'Region Map',
'Timelion',
'Visual Builder',
'Controls',
'Markdown',
'Tag Cloud',
];

View file

@ -0,0 +1,137 @@
import expect from 'expect.js';
export default function ({ getService, getPageObjects }) {
const filterBar = getService('filterBar');
const PageObjects = getPageObjects(['common', 'visualize', 'header']);
const testSubjects = getService('testSubjects');
const FIELD_NAME = 'machine.os.raw';
describe('visualize control app', () => {
before(async () => {
await PageObjects.common.navigateToUrl('visualize', 'new');
await PageObjects.visualize.clickInputControlVis();
await PageObjects.visualize.clickVisEditorTab('controls');
await PageObjects.visualize.addInputControl();
await PageObjects.visualize.setReactSelect('.index-pattern-react-select', '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.clickGo();
await PageObjects.header.waitUntilLoadingHasFinished();
});
describe('input control visualization', () => {
describe('updateFiltersOnChange is false', () => {
it('should contain dropdown with terms aggregation results as options', async () => {
const menu = await PageObjects.visualize.getReactSelectOptions('inputControl0');
expect(menu.trim().split('\n').join()).to.equal('win 8,win xp,win 7,ios,osx');
});
it('should display staging control buttons', async () => {
const submitButtonExists = await testSubjects.exists('inputControlSubmitBtn');
const cancelButtonExists = await testSubjects.exists('inputControlCancelBtn');
const clearButtonExists = await testSubjects.exists('inputControlClearBtn');
expect(submitButtonExists).to.equal(true);
expect(cancelButtonExists).to.equal(true);
expect(clearButtonExists).to.equal(true);
});
it('should stage filter when item selected but not create filter pill', async () => {
await PageObjects.visualize.setReactSelect('.list-control-react-select', 'ios');
const dropdownValue = await PageObjects.visualize.getReactSelectValue('.list-control-react-select');
expect(dropdownValue.trim()).to.equal('ios');
const hasFilter = await filterBar.hasFilter(FIELD_NAME, 'ios');
expect(hasFilter).to.equal(false);
});
it('should add filter pill when submit button is clicked', async () => {
await testSubjects.click('inputControlSubmitBtn');
const hasFilter = await filterBar.hasFilter(FIELD_NAME, 'ios');
expect(hasFilter).to.equal(true);
});
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 testSubjects.click('inputControlSubmitBtn');
const hasOldFilter = await filterBar.hasFilter(FIELD_NAME, 'ios');
const hasNewFilter = await filterBar.hasFilter(FIELD_NAME, 'osx');
expect(hasOldFilter).to.equal(false);
expect(hasNewFilter).to.equal(true);
});
it('should clear dropdown when filter pill removed', async () => {
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');
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 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');
expect(hasValue).to.equal(false);
const hasFilterAfterClearBtnClicked = await filterBar.hasFilter(FIELD_NAME, 'ios');
expect(hasFilterAfterClearBtnClicked).to.equal(true);
});
it('should remove filter pill when cleared form is submitted', async () => {
await testSubjects.click('inputControlSubmitBtn');
const hasFilter = await filterBar.hasFilter(FIELD_NAME, 'ios');
expect(hasFilter).to.equal(false);
});
});
describe('updateFiltersOnChange is true', () => {
before(async () => {
await PageObjects.visualize.clickVisEditorTab('options');
await PageObjects.visualize.checkCheckbox('inputControlEditorUpdateFiltersOnChangeCheckbox');
await PageObjects.visualize.clickGo();
await PageObjects.header.waitUntilLoadingHasFinished();
});
after(async () => {
await PageObjects.visualize.clickVisEditorTab('options');
await PageObjects.visualize.uncheckCheckbox('inputControlEditorUpdateFiltersOnChangeCheckbox');
await PageObjects.visualize.clickGo();
await PageObjects.header.waitUntilLoadingHasFinished();
});
it('should not display staging control buttons', async () => {
const submitButtonExists = await testSubjects.exists('inputControlSubmitBtn');
const cancelButtonExists = await testSubjects.exists('inputControlCancelBtn');
const clearButtonExists = await testSubjects.exists('inputControlClearBtn');
expect(submitButtonExists).to.equal(false);
expect(cancelButtonExists).to.equal(false);
expect(clearButtonExists).to.equal(false);
});
it('should add filter pill when item selected', async () => {
await PageObjects.visualize.setReactSelect('.list-control-react-select', 'ios');
const dropdownValue = await PageObjects.visualize.getReactSelectValue('.list-control-react-select');
expect(dropdownValue.trim()).to.equal('ios');
const hasFilter = await filterBar.hasFilter(FIELD_NAME, 'ios');
expect(hasFilter).to.equal(true);
});
});
});
});
}

View file

@ -30,5 +30,6 @@ export default function ({ getService, loadTestFile }) {
loadTestFile(require.resolve('./_markdown_vis'));
loadTestFile(require.resolve('./_tsvb_chart'));
loadTestFile(require.resolve('./_shared_item'));
loadTestFile(require.resolve('./_input_control_vis'));
});
}

View file

@ -82,6 +82,10 @@ export function VisualizePageProvider({ getService, getPageObjects }) {
await find.clickByPartialLinkText('Heat Map');
}
async clickInputControlVis() {
await find.clickByPartialLinkText('Controls');
}
async getChartTypeCount() {
const tags = await find.allByCssSelector('a.wizard-vis-type.ng-scope');
return tags.length;
@ -131,6 +135,65 @@ export function VisualizePageProvider({ getService, getPageObjects }) {
await input.type(timeString);
}
async setReactSelect(className, value) {
const input = await find.byCssSelector(className + ' * input', 0);
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');
}
}
async clearReactSelect(className) {
await find.clickByCssSelector(className + ' * .Select-clear-zone');
}
async getReactSelectOptions(containerSelector) {
await testSubjects.click(containerSelector);
const menu = await retry.try(
async () => find.byCssSelector('.Select-menu-outer'));
return await menu.getVisibleText();
}
async doesReactSelectHaveValue(className) {
return await find.existsByCssSelector(className + ' * .Select-value-label', 0);
}
async getReactSelectValue(className) {
const hasValue = await this.doesReactSelectHaveValue(className);
if (!hasValue) {
return '';
}
const valueElement = await retry.try(
async () => find.byCssSelector(className + ' * .Select-value-label'));
return await valueElement.getVisibleText();
}
async addInputControl() {
await testSubjects.click('inputControlEditorAddBtn');
}
async checkCheckbox(selector) {
const element = await testSubjects.find(selector);
const isSelected = await element.isSelected();
if(!isSelected) {
log.debug(`checking checkbox ${selector}`);
await testSubjects.click(selector);
}
}
async uncheckCheckbox(selector) {
const element = await testSubjects.find(selector);
const isSelected = await element.isSelected();
if(isSelected) {
log.debug(`unchecking checkbox ${selector}`);
await testSubjects.click(selector);
}
}
async clickGoButton() {
await testSubjects.click('timepickerGoButton');
}
@ -293,6 +356,10 @@ export function VisualizePageProvider({ getService, getPageObjects }) {
await testSubjects.click('visualizeEditDataLink');
}
async clickVisEditorTab(tabName) {
await testSubjects.click('visEditorTab' + tabName);
}
async selectWMS() {
await find.clickByCssSelector('input[name="wms.enabled"]');
}

View file

@ -11,6 +11,12 @@ export function FilterBarProvider({ getService }) {
);
}
async removeFilter(key) {
const filterElement = await testSubjects.find(`filter & filter-key-${key}`);
await remote.moveMouseTo(filterElement);
await testSubjects.click(`filter & filter-key-${key} removeFilter-${key}`);
}
async toggleFilterEnabled(key) {
const filterElement = await testSubjects.find(`filter & filter-key-${key}`);
await remote.moveMouseTo(filterElement);