mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
* 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:
parent
96366861a9
commit
388050ea43
56 changed files with 4242 additions and 4 deletions
|
@ -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",
|
||||
|
|
9
src/core_plugins/input_control_vis/index.js
Normal file
9
src/core_plugins/input_control_vis/index.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
export default function (kibana) {
|
||||
return new kibana.Plugin({
|
||||
uiExports: {
|
||||
visTypes: [
|
||||
'plugins/input_control_vis/register_vis'
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
4
src/core_plugins/input_control_vis/package.json
Normal file
4
src/core_plugins/input_control_vis/package.json
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"name": "input_control_vis",
|
||||
"version": "kibana"
|
||||
}
|
|
@ -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>
|
||||
`;
|
|
@ -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>
|
||||
`;
|
|
@ -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>
|
||||
`;
|
|
@ -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>
|
||||
`;
|
|
@ -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
|
||||
};
|
|
@ -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
|
||||
};
|
|
@ -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));
|
||||
});
|
|
@ -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
|
||||
};
|
|
@ -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
|
||||
};
|
|
@ -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
|
||||
};
|
|
@ -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'));
|
||||
});
|
|
@ -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
|
||||
};
|
|
@ -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));
|
||||
});
|
|
@ -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
|
||||
};
|
|
@ -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'));
|
||||
});
|
|
@ -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>
|
||||
`;
|
|
@ -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>
|
||||
`;
|
|
@ -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>
|
||||
`;
|
|
@ -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
|
||||
};
|
|
@ -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
|
||||
};
|
|
@ -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
|
||||
});
|
|
@ -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
|
||||
};
|
|
@ -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
|
||||
});
|
128
src/core_plugins/input_control_vis/public/components/vis/vis.js
Normal file
128
src/core_plugins/input_control_vis/public/components/vis/vis.js
Normal 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
|
||||
};
|
|
@ -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
|
||||
);
|
||||
});
|
52
src/core_plugins/input_control_vis/public/control/control.js
Normal file
52
src/core_plugins/input_control_vis/public/control/control.js
Normal 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());
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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() };
|
||||
})
|
||||
);
|
||||
}
|
|
@ -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
|
||||
);
|
||||
}
|
78
src/core_plugins/input_control_vis/public/editor_utils.js
Normal file
78
src/core_plugins/input_control_vis/public/editor_utils.js
Normal 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;
|
||||
};
|
52
src/core_plugins/input_control_vis/public/register_vis.js
Normal file
52
src/core_plugins/input_control_vis/public/register_vis.js
Normal 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;
|
48
src/core_plugins/input_control_vis/public/vis.less
Normal file
48
src/core_plugins/input_control_vis/public/vis.less
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
126
src/core_plugins/input_control_vis/public/vis_controller.js
Normal file
126
src/core_plugins/input_control_vis/public/vis_controller.js
Normal 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 };
|
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"rootDir": "../../",
|
||||
"roots": [
|
||||
"<rootDir>/src/core_plugins/kibana/public/dashboard",
|
||||
"<rootDir>/src/core_plugins",
|
||||
"<rootDir>/ui_framework/"
|
||||
],
|
||||
"collectCoverageFrom": [
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
84
src/ui/public/styles/react-input-range.less
Normal file
84
src/ui/public/styles/react-input-range.less
Normal 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 */
|
349
src/ui/public/styles/react-select.less
Normal file
349
src/ui/public/styles/react-select.less
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
/>
|
||||
<visualization
|
||||
ng-if="editorMode==false"
|
||||
class={{vis.type.name}}
|
||||
vis="vis"
|
||||
vis-data="visData"
|
||||
ui-state="uiState"
|
||||
|
|
|
@ -30,6 +30,7 @@ export default function ({ getService, getPageObjects }) {
|
|||
'Region Map',
|
||||
'Timelion',
|
||||
'Visual Builder',
|
||||
'Controls',
|
||||
'Markdown',
|
||||
'Tag Cloud',
|
||||
];
|
||||
|
|
137
test/functional/apps/visualize/_input_control_vis.js
Normal file
137
test/functional/apps/visualize/_input_control_vis.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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"]');
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue