Shim input_control_vis for KP (#52243)

* Shim input_control_vis
* Convert input_control_vis src files to typescript
* Add Required, Optional, Required and Class types to kbn-utility-types
* Collect all ui/* imports into legacy imports file
* Pass down plugin deps from top level
* Add timeout and terminate_after options to SearchSourceFields
This commit is contained in:
Nick Partridge 2019-12-18 16:25:35 -06:00 committed by GitHub
parent 87a9b6b6ae
commit 72c5c5cb22
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
79 changed files with 2071 additions and 1309 deletions

View file

@ -18,6 +18,7 @@
*/
import { PromiseType } from 'utility-types';
export { $Values, Required, Optional, Class } from 'utility-types';
/**
* Returns wrapped type of a promise.

View file

@ -13,7 +13,7 @@
"clean": "del target"
},
"dependencies": {
"utility-types": "^3.7.0"
"utility-types": "^3.10.0"
},
"devDependencies": {
"del-cli": "^3.0.0",

View file

@ -61,7 +61,6 @@ export function ScriptHighlightRules() {
},
{
token: 'script.keyword.operator',
regex:
'\\?\\.|\\*\\.|=~|==~|!|%|&|\\*|\\-\\-|\\-|\\+\\+|\\+|~|===|==|=|!=|!==|<=|>=|<<=|>>=|>>>=|<>|<|>|->|!|&&|\\|\\||\\?\\:|\\*=|%=|\\+=|\\-=|&=|\\^=|\\b(?:in|instanceof|new|typeof|void)',
},

View file

@ -0,0 +1,44 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { resolve } from 'path';
import { Legacy } from 'kibana';
import { LegacyPluginApi, LegacyPluginInitializer } from '../../../../src/legacy/types';
const inputControlVisPluginInitializer: LegacyPluginInitializer = ({ Plugin }: LegacyPluginApi) =>
new Plugin({
id: 'input_control_vis',
require: ['kibana', 'elasticsearch', 'visualizations', 'interpreter', 'data'],
publicDir: resolve(__dirname, 'public'),
uiExports: {
styleSheetPaths: resolve(__dirname, 'public/index.scss'),
hacks: [resolve(__dirname, 'public/legacy')],
injectDefaultVars: server => ({}),
},
init: (server: Legacy.Server) => ({}),
config(Joi: any) {
return Joi.object({
enabled: Joi.boolean().default(true),
}).default();
},
} as Legacy.PluginSpecOptions);
// eslint-disable-next-line import/no-default-export
export default inputControlVisPluginInitializer;

View file

@ -16,9 +16,33 @@ exports[`renders ControlsTab 1`] = `
"size": 5,
"type": "terms",
},
"parent": "parent",
"type": "list",
}
}
deps={
Object {
"core": Object {
"getStartServices": [MockFunction],
"injectedMetadata": Object {
"getInjectedVar": [MockFunction],
},
},
"data": Object {
"query": Object {
"filterManager": Object {
"fieldName": "myField",
"getAppFilters": [MockFunction],
"getGlobalFilters": [MockFunction],
"getIndexPattern": [Function],
},
"timefilter": Object {
"timefilter": Object {},
},
},
},
}
}
getIndexPattern={[Function]}
handleCheckboxOptionChange={[Function]}
handleFieldNameChange={[Function]}
@ -49,9 +73,33 @@ exports[`renders ControlsTab 1`] = `
"options": Object {
"step": 1,
},
"parent": "parent",
"type": "range",
}
}
deps={
Object {
"core": Object {
"getStartServices": [MockFunction],
"injectedMetadata": Object {
"getInjectedVar": [MockFunction],
},
},
"data": Object {
"query": Object {
"filterManager": Object {
"fieldName": "myField",
"getAppFilters": [MockFunction],
"getGlobalFilters": [MockFunction],
"getIndexPattern": [Function],
},
"timefilter": Object {
"timefilter": Object {},
},
},
},
}
}
getIndexPattern={[Function]}
handleCheckboxOptionChange={[Function]}
handleFieldNameChange={[Function]}

View file

@ -3,6 +3,7 @@
exports[`renders dynamic options should display disabled dynamic options with tooltip for non-string fields 1`] = `
<Fragment>
<InjectIntl(IndexPatternSelectFormRowUi)
IndexPatternSelect={[Function]}
controlIndex={0}
indexPatternId="mockIndexPattern"
onChange={[Function]}
@ -116,6 +117,7 @@ exports[`renders dynamic options should display disabled dynamic options with to
exports[`renders dynamic options should display dynamic options for string fields 1`] = `
<Fragment>
<InjectIntl(IndexPatternSelectFormRowUi)
IndexPatternSelect={[Function]}
controlIndex={0}
indexPatternId="mockIndexPattern"
onChange={[Function]}
@ -195,6 +197,7 @@ exports[`renders dynamic options should display dynamic options for string field
exports[`renders dynamic options should display size field when dynamic options is disabled 1`] = `
<Fragment>
<InjectIntl(IndexPatternSelectFormRowUi)
IndexPatternSelect={[Function]}
controlIndex={0}
indexPatternId="mockIndexPattern"
onChange={[Function]}
@ -308,6 +311,7 @@ exports[`renders dynamic options should display size field when dynamic options
exports[`renders should display chaining input when parents are provided 1`] = `
<Fragment>
<InjectIntl(IndexPatternSelectFormRowUi)
IndexPatternSelect={[Function]}
controlIndex={0}
indexPatternId="indexPattern1"
onChange={[Function]}
@ -366,6 +370,7 @@ exports[`renders should display chaining input when parents are provided 1`] = `
},
]
}
value=""
/>
</EuiFormRow>
<EuiFormRow
@ -469,6 +474,7 @@ exports[`renders should display chaining input when parents are provided 1`] = `
exports[`renders should not display any options until field is selected 1`] = `
<Fragment>
<InjectIntl(IndexPatternSelectFormRowUi)
IndexPatternSelect={[Function]}
controlIndex={0}
indexPatternId="mockIndexPattern"
onChange={[Function]}

View file

@ -56,6 +56,7 @@ exports[`OptionsTab should renders OptionsTab 1`] = `
labelType="label"
>
<EuiSwitch
checked={false}
data-test-subj="inputControlEditorPinFiltersCheckbox"
label={
<FormattedMessage

View file

@ -3,6 +3,7 @@
exports[`renders RangeControlEditor 1`] = `
<Fragment>
<InjectIntl(IndexPatternSelectFormRowUi)
IndexPatternSelect={[Function]}
controlIndex={0}
indexPatternId="indexPattern1"
onChange={[Function]}

View file

@ -0,0 +1,76 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { FieldList } from 'src/plugins/data/public';
import { InputControlVisDependencies } from '../../../plugin';
const fields: FieldList = [] as any;
fields.push({ name: 'myField' } as any);
fields.getByName = (name: any) => {
return fields.find(({ name: n }) => n === name);
};
export const getDepsMock = (): InputControlVisDependencies =>
({
core: {
getStartServices: jest.fn().mockReturnValue([
null,
{
data: {
ui: {
IndexPatternSelect: () => (<div />) as any,
},
indexPatterns: {
get: () => ({
fields,
}),
},
},
},
]),
injectedMetadata: {
getInjectedVar: jest.fn().mockImplementation(key => {
switch (key) {
case 'autocompleteTimeout':
return 1000;
case 'autocompleteTerminateAfter':
return 100000;
default:
return '';
}
}),
},
},
data: {
query: {
filterManager: {
fieldName: 'myField',
getIndexPattern: () => ({
fields,
}),
getAppFilters: jest.fn().mockImplementation(() => []),
getGlobalFilters: jest.fn().mockImplementation(() => []),
},
timefilter: {
timefilter: {},
},
},
},
} as any);

View file

@ -17,7 +17,12 @@
* under the License.
*/
export const getIndexPatternMock = () => {
import { IIndexPattern } from '../../../../../../../plugins/data/public';
/**
* Returns forced **Partial** IndexPattern for use in tests
*/
export const getIndexPatternMock = (): Promise<IIndexPattern> => {
return Promise.resolve({
id: 'mockIndexPattern',
title: 'mockIndexPattern',
@ -26,5 +31,5 @@ export const getIndexPatternMock = () => {
{ name: 'textField', type: 'string', aggregatable: false },
{ name: 'numberField', type: 'number', aggregatable: true },
],
});
} as IIndexPattern);
};

View file

@ -0,0 +1,46 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { SearchSource } from '../../../legacy_imports';
export const getSearchSourceMock = (esSearchResponse?: any): SearchSource =>
jest.fn().mockImplementation(() => ({
setParent: jest.fn(),
setField: jest.fn(),
fetch: jest.fn().mockResolvedValue(
esSearchResponse
? esSearchResponse
: {
aggregations: {
termsAgg: {
buckets: [
{
key: 'Zurich Airport',
doc_count: 691,
},
{
key: 'Xi an Xianyang International Airport',
doc_count: 526,
},
],
},
},
}
),
}));

View file

@ -0,0 +1,31 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ShallowWrapper, ReactWrapper } from 'enzyme';
export const updateComponent = async (
component:
| ShallowWrapper<any, Readonly<{}>, React.Component<{}, {}, any>>
| ReactWrapper<any, Readonly<{}>, React.Component<{}, {}, any>>
) => {
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
};

View file

@ -17,13 +17,10 @@
* under the License.
*/
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';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import React, { PureComponent, ChangeEvent } from 'react';
import { InjectedIntlProps } from 'react-intl';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import {
EuiAccordion,
EuiButtonIcon,
@ -32,11 +29,45 @@ import {
EuiFormRow,
EuiPanel,
EuiSpacer,
EuiSwitchEvent,
} from '@elastic/eui';
class ControlEditorUi extends Component {
changeLabel = evt => {
this.props.handleLabelChange(this.props.controlIndex, evt);
import { RangeControlEditor } from './range_control_editor';
import { ListControlEditor } from './list_control_editor';
import { getTitle, ControlParams, CONTROL_TYPES, ControlParamsOptions } from '../../editor_utils';
import { IIndexPattern } from '../../../../../../plugins/data/public';
import { InputControlVisDependencies } from '../../plugin';
interface ControlEditorUiProps {
controlIndex: number;
controlParams: ControlParams;
handleLabelChange: (controlIndex: number, event: ChangeEvent<HTMLInputElement>) => void;
moveControl: (controlIndex: number, direction: number) => void;
handleRemoveControl: (controlIndex: number) => void;
handleIndexPatternChange: (controlIndex: number, indexPatternId: string) => void;
handleFieldNameChange: (controlIndex: number, fieldName: string) => void;
getIndexPattern: (indexPatternId: string) => Promise<IIndexPattern>;
handleCheckboxOptionChange: (
controlIndex: number,
optionName: keyof ControlParamsOptions,
event: EuiSwitchEvent
) => void;
handleNumberOptionChange: (
controlIndex: number,
optionName: keyof ControlParamsOptions,
event: ChangeEvent<HTMLInputElement>
) => void;
parentCandidates: Array<{
value: string;
text: string;
}>;
handleParentChange: (controlIndex: number, event: ChangeEvent<HTMLSelectElement>) => void;
deps: InputControlVisDependencies;
}
class ControlEditorUi extends PureComponent<ControlEditorUiProps & InjectedIntlProps> {
changeLabel = (event: ChangeEvent<HTMLInputElement>) => {
this.props.handleLabelChange(this.props.controlIndex, event);
};
removeControl = () => {
@ -51,18 +82,18 @@ class ControlEditorUi extends Component {
this.props.moveControl(this.props.controlIndex, 1);
};
changeIndexPattern = evt => {
this.props.handleIndexPatternChange(this.props.controlIndex, evt);
changeIndexPattern = (indexPatternId: string) => {
this.props.handleIndexPatternChange(this.props.controlIndex, indexPatternId);
};
changeFieldName = evt => {
this.props.handleFieldNameChange(this.props.controlIndex, evt);
changeFieldName = (fieldName: string) => {
this.props.handleFieldNameChange(this.props.controlIndex, fieldName);
};
renderEditor() {
let controlEditor = null;
switch (this.props.controlParams.type) {
case 'list':
case CONTROL_TYPES.LIST:
controlEditor = (
<ListControlEditor
controlIndex={this.props.controlIndex}
@ -74,10 +105,11 @@ class ControlEditorUi extends Component {
handleCheckboxOptionChange={this.props.handleCheckboxOptionChange}
parentCandidates={this.props.parentCandidates}
handleParentChange={this.props.handleParentChange}
deps={this.props.deps}
/>
);
break;
case 'range':
case CONTROL_TYPES.RANGE:
controlEditor = (
<RangeControlEditor
controlIndex={this.props.controlIndex}
@ -86,6 +118,7 @@ class ControlEditorUi extends Component {
handleFieldNameChange={this.changeFieldName}
getIndexPattern={this.props.getIndexPattern}
handleNumberOptionChange={this.props.handleNumberOptionChange}
deps={this.props.deps}
/>
);
break;
@ -167,24 +200,4 @@ class ControlEditorUi extends Component {
}
}
ControlEditorUi.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,
getIndexPattern: PropTypes.func.isRequired,
handleCheckboxOptionChange: PropTypes.func.isRequired,
handleNumberOptionChange: PropTypes.func.isRequired,
parentCandidates: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
})
).isRequired,
handleParentChange: PropTypes.func.isRequired,
};
export const ControlEditor = injectI18n(ControlEditorUi);

View file

@ -17,45 +17,36 @@
* under the License.
*/
jest.mock('../../../../../core_plugins/data/public/legacy', () => ({
indexPatterns: {
indexPatterns: {
get: jest.fn(),
},
},
}));
jest.mock('ui/new_platform', () => ({
npStart: {
plugins: {
data: {
ui: {
IndexPatternSelect: () => {
return <div />;
},
},
},
},
},
}));
import React from 'react';
import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers';
// @ts-ignore
import { findTestSubject } from '@elastic/eui/lib/test';
import { getDepsMock } from './__tests__/get_deps_mock';
import { getIndexPatternMock } from './__tests__/get_index_pattern_mock';
import { ControlsTab } from './controls_tab';
import { ControlsTab, ControlsTabUiProps } from './controls_tab';
const indexPatternsMock = {
get: getIndexPatternMock,
};
let props;
let props: ControlsTabUiProps;
beforeEach(() => {
props = {
deps: getDepsMock(),
vis: {
API: {
indexPatterns: indexPatternsMock,
},
type: {
name: 'test',
title: 'test',
visualization: null,
requestHandler: 'test',
responseHandler: 'test',
stage: 'beta',
requiresSearch: false,
hidden: false,
},
},
stateParams: {
controls: [
@ -71,6 +62,7 @@ beforeEach(() => {
size: 5,
order: 'desc',
},
parent: 'parent',
},
{
id: '2',
@ -81,10 +73,12 @@ beforeEach(() => {
options: {
step: 1,
},
parent: 'parent',
},
],
},
setValue: jest.fn(),
intl: null as any,
};
});
@ -105,7 +99,7 @@ describe('behavior', () => {
'controls',
expect.arrayContaining(props.stateParams.controls)
);
expect(props.setValue.mock.calls[0][1].length).toEqual(3);
expect((props.setValue as jest.Mock).mock.calls[0][1].length).toEqual(3);
});
test('remove control button', () => {
@ -120,6 +114,7 @@ describe('behavior', () => {
fieldName: 'numberField',
label: '',
type: 'range',
parent: 'parent',
options: {
step: 1,
},
@ -142,6 +137,7 @@ describe('behavior', () => {
fieldName: 'numberField',
label: '',
type: 'range',
parent: 'parent',
options: {
step: 1,
},
@ -152,6 +148,7 @@ describe('behavior', () => {
fieldName: 'keywordField',
label: 'custom label',
type: 'list',
parent: 'parent',
options: {
type: 'terms',
multiselect: true,
@ -177,6 +174,7 @@ describe('behavior', () => {
fieldName: 'numberField',
label: '',
type: 'range',
parent: 'parent',
options: {
step: 1,
},
@ -187,6 +185,7 @@ describe('behavior', () => {
fieldName: 'keywordField',
label: 'custom label',
type: 'list',
parent: 'parent',
options: {
type: 'terms',
multiselect: true,

View file

@ -17,14 +17,10 @@
* under the License.
*/
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { ControlEditor } from './control_editor';
import { addControl, moveControl, newControl, removeControl, setControl } from '../../editor_utils';
import { getLineageMap, getParentCandidates } from '../../lineage';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import { npStart } from 'ui/new_platform';
import React, { PureComponent, ChangeEvent } from 'react';
import { InjectedIntlProps } from 'react-intl';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import {
EuiButton,
EuiFlexGroup,
@ -32,55 +28,97 @@ import {
EuiFormRow,
EuiPanel,
EuiSelect,
EuiSwitchEvent,
} from '@elastic/eui';
class ControlsTabUi extends Component {
import { ControlEditor } from './control_editor';
import {
addControl,
moveControl,
newControl,
removeControl,
setControl,
ControlParams,
CONTROL_TYPES,
ControlParamsOptions,
} from '../../editor_utils';
import { getLineageMap, getParentCandidates } from '../../lineage';
import { IIndexPattern } from '../../../../../../plugins/data/public';
import { VisOptionsProps } from '../../legacy_imports';
import { InputControlVisDependencies } from '../../plugin';
interface ControlsTabUiState {
type: CONTROL_TYPES;
}
interface ControlsTabUiParams {
controls: ControlParams[];
}
type ControlsTabUiInjectedProps = InjectedIntlProps &
Pick<VisOptionsProps<ControlsTabUiParams>, 'vis' | 'stateParams' | 'setValue'> & {
deps: InputControlVisDependencies;
};
export type ControlsTabUiProps = ControlsTabUiInjectedProps;
class ControlsTabUi extends PureComponent<ControlsTabUiProps, ControlsTabUiState> {
state = {
type: 'list',
type: CONTROL_TYPES.LIST,
};
getIndexPattern = async indexPatternId => {
return await npStart.plugins.data.indexPatterns.get(indexPatternId);
getIndexPattern = async (indexPatternId: string): Promise<IIndexPattern> => {
const [, startDeps] = await this.props.deps.core.getStartServices();
return await startDeps.data.indexPatterns.get(indexPatternId);
};
onChange = value => this.props.setValue('controls', value);
onChange = (value: ControlParams[]) => this.props.setValue('controls', value);
handleLabelChange = (controlIndex, evt) => {
handleLabelChange = (controlIndex: number, event: ChangeEvent<HTMLInputElement>) => {
const updatedControl = this.props.stateParams.controls[controlIndex];
updatedControl.label = evt.target.value;
updatedControl.label = event.target.value;
this.onChange(setControl(this.props.stateParams.controls, controlIndex, updatedControl));
};
handleIndexPatternChange = (controlIndex, indexPatternId) => {
handleIndexPatternChange = (controlIndex: number, indexPatternId: string) => {
const updatedControl = this.props.stateParams.controls[controlIndex];
updatedControl.indexPattern = indexPatternId;
updatedControl.fieldName = '';
this.onChange(setControl(this.props.stateParams.controls, controlIndex, updatedControl));
};
handleFieldNameChange = (controlIndex, fieldName) => {
handleFieldNameChange = (controlIndex: number, fieldName: string) => {
const updatedControl = this.props.stateParams.controls[controlIndex];
updatedControl.fieldName = fieldName;
this.onChange(setControl(this.props.stateParams.controls, controlIndex, updatedControl));
};
handleCheckboxOptionChange = (controlIndex, optionName, evt) => {
handleCheckboxOptionChange = (
controlIndex: number,
optionName: keyof ControlParamsOptions,
event: EuiSwitchEvent
) => {
const updatedControl = this.props.stateParams.controls[controlIndex];
updatedControl.options[optionName] = evt.target.checked;
// @ts-ignore
updatedControl.options[optionName] = event.target.checked;
this.onChange(setControl(this.props.stateParams.controls, controlIndex, updatedControl));
};
handleNumberOptionChange = (controlIndex, optionName, evt) => {
handleNumberOptionChange = (
controlIndex: number,
optionName: keyof ControlParamsOptions,
event: ChangeEvent<HTMLInputElement>
) => {
const updatedControl = this.props.stateParams.controls[controlIndex];
updatedControl.options[optionName] = parseFloat(evt.target.value);
// @ts-ignore
updatedControl.options[optionName] = parseFloat(event.target.value);
this.onChange(setControl(this.props.stateParams.controls, controlIndex, updatedControl));
};
handleRemoveControl = controlIndex => {
handleRemoveControl = (controlIndex: number) => {
this.onChange(removeControl(this.props.stateParams.controls, controlIndex));
};
moveControl = (controlIndex, direction) => {
moveControl = (controlIndex: number, direction: number) => {
this.onChange(moveControl(this.props.stateParams.controls, controlIndex, direction));
};
@ -88,9 +126,9 @@ class ControlsTabUi extends Component {
this.onChange(addControl(this.props.stateParams.controls, newControl(this.state.type)));
};
handleParentChange = (controlIndex, evt) => {
handleParentChange = (controlIndex: number, event: ChangeEvent<HTMLSelectElement>) => {
const updatedControl = this.props.stateParams.controls[controlIndex];
updatedControl.parent = evt.target.value;
updatedControl.parent = event.target.value;
this.onChange(setControl(this.props.stateParams.controls, controlIndex, updatedControl));
};
@ -117,6 +155,7 @@ class ControlsTabUi extends Component {
handleNumberOptionChange={this.handleNumberOptionChange}
parentCandidates={parentCandidates}
handleParentChange={this.handleParentChange}
deps={this.props.deps}
/>
);
});
@ -137,14 +176,14 @@ class ControlsTabUi extends Component {
data-test-subj="selectControlType"
options={[
{
value: 'range',
value: CONTROL_TYPES.RANGE,
text: intl.formatMessage({
id: 'inputControl.editor.controlsTab.select.rangeDropDownOptionLabel',
defaultMessage: 'Range slider',
}),
},
{
value: 'list',
value: CONTROL_TYPES.LIST,
text: intl.formatMessage({
id: 'inputControl.editor.controlsTab.select.listDropDownOptionLabel',
defaultMessage: 'Options list',
@ -152,7 +191,7 @@ class ControlsTabUi extends Component {
},
]}
value={this.state.type}
onChange={evt => this.setState({ type: evt.target.value })}
onChange={event => this.setState({ type: event.target.value as CONTROL_TYPES })}
aria-label={intl.formatMessage({
id: 'inputControl.editor.controlsTab.select.controlTypeAriaLabel',
defaultMessage: 'Select control type',
@ -186,9 +225,8 @@ class ControlsTabUi extends Component {
}
}
ControlsTabUi.propTypes = {
vis: PropTypes.object.isRequired,
setValue: PropTypes.func.isRequired,
};
export const ControlsTab = injectI18n(ControlsTabUi);
export const getControlsTab = (deps: InputControlVisDependencies) => (
props: Omit<ControlsTabUiProps, 'core'>
) => <ControlsTab {...props} deps={deps} />;

View file

@ -18,43 +18,59 @@
*/
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { InjectedIntlProps } from 'react-intl';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import { EuiFormRow, EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui';
import { EuiFormRow, EuiComboBox } from '@elastic/eui';
import { IIndexPattern, IFieldType } from '../../../../../../plugins/data/public';
class FieldSelectUi extends Component {
constructor(props) {
interface FieldSelectUiState {
isLoading: boolean;
fields: Array<EuiComboBoxOptionProps<string>>;
indexPatternId: string;
}
export type FieldSelectUiProps = InjectedIntlProps & {
getIndexPattern: (indexPatternId: string) => Promise<IIndexPattern>;
indexPatternId: string;
onChange: (value: any) => void;
fieldName?: string;
filterField?: (field: IFieldType) => boolean;
controlIndex: number;
};
class FieldSelectUi extends Component<FieldSelectUiProps, FieldSelectUiState> {
private hasUnmounted: boolean;
constructor(props: FieldSelectUiProps) {
super(props);
this._hasUnmounted = false;
this.hasUnmounted = false;
this.state = {
isLoading: false,
fields: [],
indexPatternId: props.indexPatternId,
};
this.filterField = _.get(props, 'filterField', () => {
return true;
});
}
componentWillUnmount() {
this._hasUnmounted = true;
this.hasUnmounted = true;
}
componentDidMount() {
this.loadFields(this.state.indexPatternId);
}
UNSAFE_componentWillReceiveProps(nextProps) {
UNSAFE_componentWillReceiveProps(nextProps: FieldSelectUiProps) {
if (this.props.indexPatternId !== nextProps.indexPatternId) {
this.loadFields(nextProps.indexPatternId);
this.loadFields(nextProps.indexPatternId ?? '');
}
}
loadFields = indexPatternId => {
loadFields = (indexPatternId: string) => {
this.setState(
{
isLoading: true,
@ -65,12 +81,12 @@ class FieldSelectUi extends Component {
);
};
debouncedLoad = _.debounce(async indexPatternId => {
debouncedLoad = _.debounce(async (indexPatternId: string) => {
if (!indexPatternId || indexPatternId.length === 0) {
return;
}
let indexPattern;
let indexPattern: IIndexPattern;
try {
indexPattern = await this.props.getIndexPattern(indexPatternId);
} catch (err) {
@ -78,7 +94,7 @@ class FieldSelectUi extends Component {
return;
}
if (this._hasUnmounted) {
if (this.hasUnmounted) {
return;
}
@ -88,17 +104,15 @@ class FieldSelectUi extends Component {
return;
}
const fieldsByTypeMap = new Map();
const fields = [];
indexPattern.fields.filter(this.filterField).forEach(field => {
if (fieldsByTypeMap.has(field.type)) {
const fieldsList = fieldsByTypeMap.get(field.type);
const fieldsByTypeMap = new Map<string, string[]>();
const fields: Array<EuiComboBoxOptionProps<string>> = [];
indexPattern.fields
.filter(this.props.filterField ?? (() => true))
.forEach((field: IFieldType) => {
const fieldsList = fieldsByTypeMap.get(field.type) ?? [];
fieldsList.push(field.name);
fieldsByTypeMap.set(field.type, fieldsList);
} else {
fieldsByTypeMap.set(field.type, [field.name]);
}
});
});
fieldsByTypeMap.forEach((fieldsList, fieldType) => {
fields.push({
@ -117,11 +131,11 @@ class FieldSelectUi extends Component {
this.setState({
isLoading: false,
fields: fields,
fields,
});
}, 300);
onChange = selectedOptions => {
onChange = (selectedOptions: Array<EuiComboBoxOptionProps<any>>) => {
this.props.onChange(_.get(selectedOptions, '0.value'));
};
@ -165,13 +179,4 @@ class FieldSelectUi extends Component {
}
}
FieldSelectUi.propTypes = {
getIndexPattern: PropTypes.func.isRequired,
indexPatternId: PropTypes.string,
onChange: PropTypes.func.isRequired,
fieldName: PropTypes.string,
filterField: PropTypes.func,
controlIndex: PropTypes.number.isRequired,
};
export const FieldSelect = injectI18n(FieldSelectUi);

View file

@ -17,15 +17,20 @@
* under the License.
*/
import PropTypes from 'prop-types';
import React from 'react';
import React, { ComponentType } from 'react';
import { injectI18n } from '@kbn/i18n/react';
import { EuiFormRow } from '@elastic/eui';
import { InjectedIntlProps } from 'react-intl';
import { IndexPatternSelect } from 'src/plugins/data/public';
import { npStart } from 'ui/new_platform';
const { IndexPatternSelect } = npStart.plugins.data.ui;
export type IndexPatternSelectFormRowUiProps = InjectedIntlProps & {
onChange: (opt: any) => void;
indexPatternId: string;
controlIndex: number;
IndexPatternSelect: ComponentType<IndexPatternSelect['props']>;
};
function IndexPatternSelectFormRowUi(props) {
function IndexPatternSelectFormRowUi(props: IndexPatternSelectFormRowUiProps) {
const { controlIndex, indexPatternId, intl, onChange } = props;
const selectId = `indexPatternSelect-${controlIndex}`;
@ -37,7 +42,7 @@ function IndexPatternSelectFormRowUi(props) {
defaultMessage: 'Index Pattern',
})}
>
<IndexPatternSelect
<props.IndexPatternSelect
placeholder={intl.formatMessage({
id: 'inputControl.editor.indexPatternSelect.patternPlaceholder',
defaultMessage: 'Select index pattern...',
@ -45,15 +50,11 @@ function IndexPatternSelectFormRowUi(props) {
indexPatternId={indexPatternId}
onChange={onChange}
data-test-subj={selectId}
// TODO: supply actual savedObjectsClient here
savedObjectsClient={{} as any}
/>
</EuiFormRow>
);
}
IndexPatternSelectFormRowUi.propTypes = {
onChange: PropTypes.func.isRequired,
indexPatternId: PropTypes.string,
controlIndex: PropTypes.number.isRequired,
};
export const IndexPatternSelectFormRow = injectI18n(IndexPatternSelectFormRowUi);

View file

@ -17,31 +17,21 @@
* under the License.
*/
jest.mock('ui/new_platform', () => ({
npStart: {
plugins: {
data: {
ui: {
IndexPatternSelect: () => {
return <div />;
},
},
},
},
},
}));
import React from 'react';
import sinon from 'sinon';
import { shallow } from 'enzyme';
import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers';
// @ts-ignore
import { findTestSubject } from '@elastic/eui/lib/test';
import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers';
import { getDepsMock } from './__tests__/get_deps_mock';
import { getIndexPatternMock } from './__tests__/get_index_pattern_mock';
import { ListControlEditor } from './list_control_editor';
import { ControlParams } from '../../editor_utils';
import { updateComponent } from './__tests__/update_component';
const controlParams = {
const controlParamsBase: ControlParams = {
id: '1',
indexPattern: 'indexPattern1',
fieldName: 'keywordField',
@ -53,11 +43,13 @@ const controlParams = {
dynamicOptions: false,
size: 10,
},
parent: '',
};
let handleFieldNameChange;
let handleIndexPatternChange;
let handleCheckboxOptionChange;
let handleNumberOptionChange;
const deps = getDepsMock();
let handleFieldNameChange: sinon.SinonSpy;
let handleIndexPatternChange: sinon.SinonSpy;
let handleCheckboxOptionChange: sinon.SinonSpy;
let handleNumberOptionChange: sinon.SinonSpy;
beforeEach(() => {
handleFieldNameChange = sinon.spy();
@ -68,8 +60,9 @@ beforeEach(() => {
describe('renders', () => {
test('should not display any options until field is selected', async () => {
const controlParams = {
const controlParams: ControlParams = {
id: '1',
label: 'mock',
indexPattern: 'mockIndexPattern',
fieldName: '',
type: 'list',
@ -79,9 +72,11 @@ describe('renders', () => {
dynamicOptions: true,
size: 5,
},
parent: '',
};
const component = shallow(
<ListControlEditor
deps={deps}
getIndexPattern={getIndexPatternMock}
controlIndex={0}
controlParams={controlParams}
@ -94,10 +89,7 @@ describe('renders', () => {
/>
);
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
await updateComponent(component);
expect(component).toMatchSnapshot();
});
@ -109,9 +101,10 @@ describe('renders', () => {
];
const component = shallow(
<ListControlEditor
deps={deps}
getIndexPattern={getIndexPatternMock}
controlIndex={0}
controlParams={controlParams}
controlParams={controlParamsBase}
handleFieldNameChange={handleFieldNameChange}
handleIndexPatternChange={handleIndexPatternChange}
handleCheckboxOptionChange={handleCheckboxOptionChange}
@ -121,18 +114,16 @@ describe('renders', () => {
/>
);
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
await updateComponent(component);
expect(component).toMatchSnapshot();
});
describe('dynamic options', () => {
test('should display dynamic options for string fields', async () => {
const controlParams = {
const controlParams: ControlParams = {
id: '1',
label: 'mock',
indexPattern: 'mockIndexPattern',
fieldName: 'keywordField',
type: 'list',
@ -142,9 +133,11 @@ describe('renders', () => {
dynamicOptions: true,
size: 5,
},
parent: '',
};
const component = shallow(
<ListControlEditor
deps={deps}
getIndexPattern={getIndexPatternMock}
controlIndex={0}
controlParams={controlParams}
@ -157,17 +150,15 @@ describe('renders', () => {
/>
);
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
await updateComponent(component);
expect(component).toMatchSnapshot();
});
test('should display size field when dynamic options is disabled', async () => {
const controlParams = {
const controlParams: ControlParams = {
id: '1',
label: 'mock',
indexPattern: 'mockIndexPattern',
fieldName: 'keywordField',
type: 'list',
@ -177,9 +168,11 @@ describe('renders', () => {
dynamicOptions: false,
size: 5,
},
parent: '',
};
const component = shallow(
<ListControlEditor
deps={deps}
getIndexPattern={getIndexPatternMock}
controlIndex={0}
controlParams={controlParams}
@ -192,17 +185,15 @@ describe('renders', () => {
/>
);
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
await updateComponent(component);
expect(component).toMatchSnapshot();
});
test('should display disabled dynamic options with tooltip for non-string fields', async () => {
const controlParams = {
const controlParams: ControlParams = {
id: '1',
label: 'mock',
indexPattern: 'mockIndexPattern',
fieldName: 'numberField',
type: 'list',
@ -212,9 +203,11 @@ describe('renders', () => {
dynamicOptions: true,
size: 5,
},
parent: '',
};
const component = shallow(
<ListControlEditor
deps={deps}
getIndexPattern={getIndexPatternMock}
controlIndex={0}
controlParams={controlParams}
@ -227,10 +220,7 @@ describe('renders', () => {
/>
);
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
await updateComponent(component);
expect(component).toMatchSnapshot();
});
@ -240,9 +230,10 @@ describe('renders', () => {
test('handleCheckboxOptionChange - multiselect', async () => {
const component = mountWithIntl(
<ListControlEditor
deps={deps}
getIndexPattern={getIndexPatternMock}
controlIndex={0}
controlParams={controlParams}
controlParams={controlParamsBase}
handleFieldNameChange={handleFieldNameChange}
handleIndexPatternChange={handleIndexPatternChange}
handleCheckboxOptionChange={handleCheckboxOptionChange}
@ -252,10 +243,7 @@ test('handleCheckboxOptionChange - multiselect', async () => {
/>
);
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
await updateComponent(component);
const checkbox = findTestSubject(component, 'listControlMultiselectInput');
checkbox.simulate('click');
@ -268,10 +256,10 @@ test('handleCheckboxOptionChange - multiselect', async () => {
handleCheckboxOptionChange,
expectedControlIndex,
expectedOptionName,
sinon.match(evt => {
// Synthetic `evt.target.checked` does not get altered by EuiSwitch,
sinon.match(event => {
// Synthetic `event.target.checked` does not get altered by EuiSwitch,
// but its aria attribute is correctly updated
if (evt.target.getAttribute('aria-checked') === 'true') {
if (event.target.getAttribute('aria-checked') === 'true') {
return true;
}
return false;
@ -282,9 +270,10 @@ test('handleCheckboxOptionChange - multiselect', async () => {
test('handleNumberOptionChange - size', async () => {
const component = mountWithIntl(
<ListControlEditor
deps={deps}
getIndexPattern={getIndexPatternMock}
controlIndex={0}
controlParams={controlParams}
controlParams={controlParamsBase}
handleFieldNameChange={handleFieldNameChange}
handleIndexPatternChange={handleIndexPatternChange}
handleCheckboxOptionChange={handleCheckboxOptionChange}
@ -294,10 +283,7 @@ test('handleNumberOptionChange - size', async () => {
/>
);
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
await updateComponent(component);
const input = findTestSubject(component, 'listControlSizeInput');
input.simulate('change', { target: { value: 7 } });
@ -310,8 +296,8 @@ test('handleNumberOptionChange - size', async () => {
handleNumberOptionChange,
expectedControlIndex,
expectedOptionName,
sinon.match(evt => {
if (evt.target.value === 7) {
sinon.match(event => {
if (event.target.value === 7) {
return true;
}
return false;
@ -322,9 +308,10 @@ test('handleNumberOptionChange - size', async () => {
test('field name change', async () => {
const component = shallowWithIntl(
<ListControlEditor
deps={deps}
getIndexPattern={getIndexPatternMock}
controlIndex={0}
controlParams={controlParams}
controlParams={controlParamsBase}
handleFieldNameChange={handleFieldNameChange}
handleIndexPatternChange={handleIndexPatternChange}
handleCheckboxOptionChange={handleCheckboxOptionChange}
@ -334,25 +321,18 @@ test('field name change', async () => {
/>
);
const update = async () => {
// Ensure all promises resolve
await new Promise(resolve => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
};
// ensure that after async loading is complete the DynamicOptionsSwitch is not disabled
expect(
component.find('[data-test-subj="listControlDynamicOptionsSwitch"][disabled=false]')
).toHaveLength(0);
await update();
await updateComponent(component);
expect(
component.find('[data-test-subj="listControlDynamicOptionsSwitch"][disabled=false]')
).toHaveLength(1);
component.setProps({
controlParams: {
...controlParams,
...controlParamsBase,
fieldName: 'numberField',
},
});
@ -361,20 +341,20 @@ test('field name change', async () => {
expect(
component.find('[data-test-subj="listControlDynamicOptionsSwitch"][disabled=true]')
).toHaveLength(0);
await update();
await updateComponent(component);
expect(
component.find('[data-test-subj="listControlDynamicOptionsSwitch"][disabled=true]')
).toHaveLength(1);
component.setProps({
controlParams,
controlParams: controlParamsBase,
});
// ensure that after async loading is complete the DynamicOptionsSwitch is not disabled again, because we switched to original "string" field
expect(
component.find('[data-test-subj="listControlDynamicOptionsSwitch"][disabled=false]')
).toHaveLength(0);
await update();
await updateComponent(component);
expect(
component.find('[data-test-subj="listControlDynamicOptionsSwitch"][disabled=false]')
).toHaveLength(1);

View file

@ -17,35 +17,90 @@
* under the License.
*/
import PropTypes from 'prop-types';
import React, { Component, Fragment } from 'react';
import React, { PureComponent, ChangeEvent, ComponentType } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiFormRow,
EuiFieldNumber,
EuiSwitch,
EuiSelect,
EuiSelectProps,
EuiSwitchEvent,
} from '@elastic/eui';
import { IndexPatternSelectFormRow } from './index_pattern_select_form_row';
import { FieldSelect } from './field_select';
import { FormattedMessage } from '@kbn/i18n/react';
import { ControlParams, ControlParamsOptions } from '../../editor_utils';
import {
IIndexPattern,
IFieldType,
IndexPatternSelect,
} from '../../../../../../plugins/data/public';
import { InputControlVisDependencies } from '../../plugin';
import { EuiFormRow, EuiFieldNumber, EuiSwitch, EuiSelect } from '@elastic/eui';
function filterField(field) {
return field.aggregatable && ['number', 'boolean', 'date', 'ip', 'string'].includes(field.type);
interface ListControlEditorState {
isLoadingFieldType: boolean;
isStringField: boolean;
prevFieldName: string;
IndexPatternSelect: ComponentType<IndexPatternSelect['props']> | null;
}
export class ListControlEditor extends Component {
state = {
interface ListControlEditorProps {
getIndexPattern: (indexPatternId: string) => Promise<IIndexPattern>;
controlIndex: number;
controlParams: ControlParams;
handleFieldNameChange: (fieldName: string) => void;
handleIndexPatternChange: (indexPatternId: string) => void;
handleCheckboxOptionChange: (
controlIndex: number,
optionName: keyof ControlParamsOptions,
event: EuiSwitchEvent
) => void;
handleNumberOptionChange: (
controlIndex: number,
optionName: keyof ControlParamsOptions,
event: ChangeEvent<HTMLInputElement>
) => void;
handleParentChange: (controlIndex: number, event: ChangeEvent<HTMLSelectElement>) => void;
parentCandidates: EuiSelectProps['options'];
deps: InputControlVisDependencies;
}
function filterField(field: IFieldType) {
return (
Boolean(field.aggregatable) &&
['number', 'boolean', 'date', 'ip', 'string'].includes(field.type)
);
}
export class ListControlEditor extends PureComponent<
ListControlEditorProps,
ListControlEditorState
> {
private isMounted: boolean = false;
state: ListControlEditorState = {
isLoadingFieldType: true,
isStringField: false,
prevFieldName: this.props.controlParams.fieldName,
IndexPatternSelect: null,
};
componentDidMount() {
this._isMounted = true;
this.isMounted = true;
this.loadIsStringField();
this.getIndexPatternSelect();
}
componentWillUnmount() {
this._isMounted = false;
this.isMounted = false;
}
static getDerivedStateFromProps = (nextProps, prevState) => {
static getDerivedStateFromProps = (
nextProps: ListControlEditorProps,
prevState: ListControlEditorState
) => {
const isNewFieldName = prevState.prevFieldName !== nextProps.controlParams.fieldName;
if (!prevState.isLoadingFieldType && isNewFieldName) {
return {
@ -63,13 +118,20 @@ export class ListControlEditor extends Component {
}
};
async getIndexPatternSelect() {
const [, { data }] = await this.props.deps.core.getStartServices();
this.setState({
IndexPatternSelect: data.ui.IndexPatternSelect,
});
}
loadIsStringField = async () => {
if (!this.props.controlParams.indexPattern || !this.props.controlParams.fieldName) {
this.setState({ isLoadingFieldType: false });
return;
}
let indexPattern;
let indexPattern: IIndexPattern;
try {
indexPattern = await this.props.getIndexPattern(this.props.controlParams.indexPattern);
} catch (err) {
@ -77,13 +139,13 @@ export class ListControlEditor extends Component {
return;
}
if (!this._isMounted) {
if (!this.isMounted) {
return;
}
const field = indexPattern.fields.find(field => {
return field.name === this.props.controlParams.fieldName;
});
const field = (indexPattern.fields as IFieldType[]).find(
({ name }) => name === this.props.controlParams.fieldName
);
if (!field) {
return;
}
@ -121,8 +183,8 @@ export class ListControlEditor extends Component {
<EuiSelect
options={parentCandidatesOptions}
value={this.props.controlParams.parent}
onChange={evt => {
this.props.handleParentChange(this.props.controlIndex, evt);
onChange={event => {
this.props.handleParentChange(this.props.controlIndex, event);
}}
/>
</EuiFormRow>
@ -147,9 +209,9 @@ export class ListControlEditor extends Component {
defaultMessage="Multiselect"
/>
}
checked={this.props.controlParams.options.multiselect}
onChange={evt => {
this.props.handleCheckboxOptionChange(this.props.controlIndex, 'multiselect', evt);
checked={this.props.controlParams.options.multiselect ?? true}
onChange={event => {
this.props.handleCheckboxOptionChange(this.props.controlIndex, 'multiselect', event);
}}
data-test-subj="listControlMultiselectInput"
/>
@ -180,9 +242,9 @@ export class ListControlEditor extends Component {
defaultMessage="Dynamic Options"
/>
}
checked={this.props.controlParams.options.dynamicOptions}
onChange={evt => {
this.props.handleCheckboxOptionChange(this.props.controlIndex, 'dynamicOptions', evt);
checked={this.props.controlParams.options.dynamicOptions ?? false}
onChange={event => {
this.props.handleCheckboxOptionChange(this.props.controlIndex, 'dynamicOptions', event);
}}
disabled={this.state.isStringField ? false : true}
data-test-subj="listControlDynamicOptionsSwitch"
@ -212,8 +274,8 @@ export class ListControlEditor extends Component {
<EuiFieldNumber
min={1}
value={this.props.controlParams.options.size}
onChange={evt => {
this.props.handleNumberOptionChange(this.props.controlIndex, 'size', evt);
onChange={event => {
this.props.handleNumberOptionChange(this.props.controlIndex, 'size', event);
}}
data-test-subj="listControlSizeInput"
/>
@ -225,12 +287,17 @@ export class ListControlEditor extends Component {
};
render() {
if (this.state.IndexPatternSelect === null) {
return null;
}
return (
<Fragment>
<>
<IndexPatternSelectFormRow
indexPatternId={this.props.controlParams.indexPattern}
onChange={this.props.handleIndexPatternChange}
controlIndex={this.props.controlIndex}
IndexPatternSelect={this.state.IndexPatternSelect}
/>
<FieldSelect
@ -243,24 +310,7 @@ export class ListControlEditor extends Component {
/>
{this.renderOptions()}
</Fragment>
</>
);
}
}
ListControlEditor.propTypes = {
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,
parentCandidates: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
})
).isRequired,
handleParentChange: PropTypes.func.isRequired,
};

View file

@ -21,17 +21,19 @@ import React from 'react';
import { shallow } from 'enzyme';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { OptionsTab } from './options_tab';
import { OptionsTab, OptionsTabProps } from './options_tab';
import { Vis } from '../../legacy_imports';
describe('OptionsTab', () => {
let props;
let props: OptionsTabProps;
beforeEach(() => {
props = {
vis: {},
vis: {} as Vis,
stateParams: {
updateFiltersOnChange: false,
useTimeFilter: false,
pinFilters: false,
},
setValue: jest.fn(),
};

View file

@ -17,24 +17,37 @@
* under the License.
*/
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import React, { PureComponent } from 'react';
import { EuiForm, EuiFormRow, EuiSwitch } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiSwitchEvent } from '@elastic/eui';
export class OptionsTab extends Component {
handleUpdateFiltersChange = evt => {
this.props.setValue('updateFiltersOnChange', evt.target.checked);
import { VisOptionsProps } from '../../legacy_imports';
interface OptionsTabParams {
updateFiltersOnChange: boolean;
useTimeFilter: boolean;
pinFilters: boolean;
}
type OptionsTabInjectedProps = Pick<
VisOptionsProps<OptionsTabParams>,
'vis' | 'setValue' | 'stateParams'
>;
export type OptionsTabProps = OptionsTabInjectedProps;
export class OptionsTab extends PureComponent<OptionsTabProps> {
handleUpdateFiltersChange = (event: EuiSwitchEvent) => {
this.props.setValue('updateFiltersOnChange', event.target.checked);
};
handleUseTimeFilter = evt => {
this.props.setValue('useTimeFilter', evt.target.checked);
handleUseTimeFilter = (event: EuiSwitchEvent) => {
this.props.setValue('useTimeFilter', event.target.checked);
};
handlePinFilters = evt => {
this.props.setValue('pinFilters', evt.target.checked);
handlePinFilters = (event: EuiSwitchEvent) => {
this.props.setValue('pinFilters', event.target.checked);
};
render() {
@ -85,8 +98,3 @@ export class OptionsTab extends Component {
);
}
}
OptionsTab.propTypes = {
vis: PropTypes.object.isRequired,
setValue: PropTypes.func.isRequired,
};

View file

@ -1,102 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import PropTypes from 'prop-types';
import React, { Fragment } from 'react';
import { IndexPatternSelectFormRow } from './index_pattern_select_form_row';
import { FieldSelect } from './field_select';
import { EuiFormRow, EuiFieldNumber } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
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 (
<Fragment>
<IndexPatternSelectFormRow
indexPatternId={props.controlParams.indexPattern}
onChange={props.handleIndexPatternChange}
controlIndex={props.controlIndex}
/>
<FieldSelect
fieldName={props.controlParams.fieldName}
indexPatternId={props.controlParams.indexPattern}
filterField={filterField}
onChange={props.handleFieldNameChange}
getIndexPattern={props.getIndexPattern}
controlIndex={props.controlIndex}
/>
<EuiFormRow
id={stepSizeId}
label={
<FormattedMessage
id="inputControl.editor.rangeControl.stepSizeLabel"
defaultMessage="Step Size"
/>
}
>
<EuiFieldNumber
value={props.controlParams.options.step}
onChange={handleStepChange}
data-test-subj={`rangeControlSizeInput${props.controlIndex}`}
/>
</EuiFormRow>
<EuiFormRow
id={decimalPlacesId}
label={
<FormattedMessage
id="inputControl.editor.rangeControl.decimalPlacesLabel"
defaultMessage="Decimal Places"
/>
}
>
<EuiFieldNumber
min={0}
value={props.controlParams.options.decimalPlaces}
onChange={handleDecimalPlacesChange}
data-test-subj={`rangeControlDecimalPlacesInput${props.controlIndex}`}
/>
</EuiFormRow>
</Fragment>
);
}
RangeControlEditor.propTypes = {
getIndexPattern: PropTypes.func.isRequired,
controlIndex: PropTypes.number.isRequired,
controlParams: PropTypes.object.isRequired,
handleFieldNameChange: PropTypes.func.isRequired,
handleIndexPatternChange: PropTypes.func.isRequired,
handleNumberOptionChange: PropTypes.func.isRequired,
};

View file

@ -18,30 +18,20 @@
*/
import React from 'react';
import sinon from 'sinon';
import { shallow } from 'enzyme';
import { SinonSpy, spy, assert, match } from 'sinon';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
jest.mock('ui/new_platform', () => ({
npStart: {
plugins: {
data: {
ui: {
IndexPatternSelect: () => {
return <div />;
},
},
},
},
},
}));
// @ts-ignore
import { findTestSubject } from '@elastic/eui/lib/test';
import { getIndexPatternMock } from './__tests__/get_index_pattern_mock';
import { RangeControlEditor } from './range_control_editor';
import { ControlParams } from '../../editor_utils';
import { getDepsMock } from './__tests__/get_deps_mock';
import { updateComponent } from './__tests__/update_component';
const controlParams = {
const controlParams: ControlParams = {
id: '1',
indexPattern: 'indexPattern1',
fieldName: 'numberField',
@ -51,20 +41,23 @@ const controlParams = {
decimalPlaces: 0,
step: 1,
},
parent: '',
};
let handleFieldNameChange;
let handleIndexPatternChange;
let handleNumberOptionChange;
const deps = getDepsMock();
let handleFieldNameChange: SinonSpy;
let handleIndexPatternChange: SinonSpy;
let handleNumberOptionChange: SinonSpy;
beforeEach(() => {
handleFieldNameChange = sinon.spy();
handleIndexPatternChange = sinon.spy();
handleNumberOptionChange = sinon.spy();
handleFieldNameChange = spy();
handleIndexPatternChange = spy();
handleNumberOptionChange = spy();
});
test('renders RangeControlEditor', () => {
test('renders RangeControlEditor', async () => {
const component = shallow(
<RangeControlEditor
deps={deps}
getIndexPattern={getIndexPatternMock}
controlIndex={0}
controlParams={controlParams}
@ -73,12 +66,16 @@ test('renders RangeControlEditor', () => {
handleNumberOptionChange={handleNumberOptionChange}
/>
);
await updateComponent(component);
expect(component).toMatchSnapshot(); // eslint-disable-line
});
test('handleNumberOptionChange - step', () => {
test('handleNumberOptionChange - step', async () => {
const component = mountWithIntl(
<RangeControlEditor
deps={deps}
getIndexPattern={getIndexPatternMock}
controlIndex={0}
controlParams={controlParams}
@ -87,19 +84,22 @@ test('handleNumberOptionChange - step', () => {
handleNumberOptionChange={handleNumberOptionChange}
/>
);
await updateComponent(component);
findTestSubject(component, 'rangeControlSizeInput0').simulate('change', {
target: { value: 0.5 },
});
sinon.assert.notCalled(handleFieldNameChange);
sinon.assert.notCalled(handleIndexPatternChange);
assert.notCalled(handleFieldNameChange);
assert.notCalled(handleIndexPatternChange);
const expectedControlIndex = 0;
const expectedOptionName = 'step';
sinon.assert.calledWith(
assert.calledWith(
handleNumberOptionChange,
expectedControlIndex,
expectedOptionName,
sinon.match(evt => {
if (evt.target.value === 0.5) {
match(event => {
if (event.target.value === 0.5) {
return true;
}
return false;
@ -107,9 +107,10 @@ test('handleNumberOptionChange - step', () => {
);
});
test('handleNumberOptionChange - decimalPlaces', () => {
test('handleNumberOptionChange - decimalPlaces', async () => {
const component = mountWithIntl(
<RangeControlEditor
deps={deps}
getIndexPattern={getIndexPatternMock}
controlIndex={0}
controlParams={controlParams}
@ -118,19 +119,22 @@ test('handleNumberOptionChange - decimalPlaces', () => {
handleNumberOptionChange={handleNumberOptionChange}
/>
);
await updateComponent(component);
findTestSubject(component, 'rangeControlDecimalPlacesInput0').simulate('change', {
target: { value: 2 },
});
sinon.assert.notCalled(handleFieldNameChange);
sinon.assert.notCalled(handleIndexPatternChange);
assert.notCalled(handleFieldNameChange);
assert.notCalled(handleIndexPatternChange);
const expectedControlIndex = 0;
const expectedOptionName = 'decimalPlaces';
sinon.assert.calledWith(
assert.calledWith(
handleNumberOptionChange,
expectedControlIndex,
expectedOptionName,
sinon.match(evt => {
if (evt.target.value === 2) {
match(event => {
if (event.target.value === 2) {
return true;
}
return false;

View file

@ -0,0 +1,139 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { Component, Fragment, ChangeEvent, ComponentType } from 'react';
import { EuiFormRow, EuiFieldNumber } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { IndexPatternSelectFormRow } from './index_pattern_select_form_row';
import { FieldSelect } from './field_select';
import { ControlParams, ControlParamsOptions } from '../../editor_utils';
import {
IIndexPattern,
IFieldType,
IndexPatternSelect,
} from '../../../../../../plugins/data/public';
import { InputControlVisDependencies } from '../../plugin';
interface RangeControlEditorProps {
controlIndex: number;
controlParams: ControlParams;
getIndexPattern: (indexPatternId: string) => Promise<IIndexPattern>;
handleFieldNameChange: (fieldName: string) => void;
handleIndexPatternChange: (indexPatternId: string) => void;
handleNumberOptionChange: (
controlIndex: number,
optionName: keyof ControlParamsOptions,
event: ChangeEvent<HTMLInputElement>
) => void;
deps: InputControlVisDependencies;
}
interface RangeControlEditorState {
IndexPatternSelect: ComponentType<IndexPatternSelect['props']> | null;
}
function filterField(field: IFieldType) {
return field.type === 'number';
}
export class RangeControlEditor extends Component<
RangeControlEditorProps,
RangeControlEditorState
> {
state: RangeControlEditorState = {
IndexPatternSelect: null,
};
componentDidMount() {
this.getIndexPatternSelect();
}
async getIndexPatternSelect() {
const [, { data }] = await this.props.deps.core.getStartServices();
this.setState({
IndexPatternSelect: data.ui.IndexPatternSelect,
});
}
render() {
const stepSizeId = `stepSize-${this.props.controlIndex}`;
const decimalPlacesId = `decimalPlaces-${this.props.controlIndex}`;
if (this.state.IndexPatternSelect === null) {
return null;
}
return (
<Fragment>
<IndexPatternSelectFormRow
indexPatternId={this.props.controlParams.indexPattern}
onChange={this.props.handleIndexPatternChange}
controlIndex={this.props.controlIndex}
IndexPatternSelect={this.state.IndexPatternSelect}
/>
<FieldSelect
fieldName={this.props.controlParams.fieldName}
indexPatternId={this.props.controlParams.indexPattern}
filterField={filterField}
onChange={this.props.handleFieldNameChange}
getIndexPattern={this.props.getIndexPattern}
controlIndex={this.props.controlIndex}
/>
<EuiFormRow
id={stepSizeId}
label={
<FormattedMessage
id="inputControl.editor.rangeControl.stepSizeLabel"
defaultMessage="Step Size"
/>
}
>
<EuiFieldNumber
value={this.props.controlParams.options.step}
onChange={event => {
this.props.handleNumberOptionChange(this.props.controlIndex, 'step', event);
}}
data-test-subj={`rangeControlSizeInput${this.props.controlIndex}`}
/>
</EuiFormRow>
<EuiFormRow
id={decimalPlacesId}
label={
<FormattedMessage
id="inputControl.editor.rangeControl.decimalPlacesLabel"
defaultMessage="Decimal Places"
/>
}
>
<EuiFieldNumber
min={0}
value={this.props.controlParams.options.decimalPlaces}
onChange={event => {
this.props.handleNumberOptionChange(this.props.controlIndex, 'decimalPlaces', event);
}}
data-test-subj={`rangeControlDecimalPlacesInput${this.props.controlIndex}`}
/>
</EuiFormRow>
</Fragment>
);
}
}

View file

@ -47,7 +47,6 @@ exports[`renders disabled control with tooltip 1`] = `
anchorClassName="eui-displayBlock"
content="I am disabled for testing purposes"
delay="regular"
placement="top"
position="top"
>
<div>

View file

@ -18,7 +18,6 @@ exports[`Apply and Cancel change btns enabled when there are changes 1`] = `
>
<InjectIntl(ListControlUi)
controlIndex={0}
disableMsg={null}
fetchOptions={[Function]}
formatOptionLabel={[Function]}
id="mock-list-control"
@ -106,7 +105,6 @@ exports[`Clear btns enabled when there are values 1`] = `
>
<InjectIntl(ListControlUi)
controlIndex={0}
disableMsg={null}
fetchOptions={[Function]}
formatOptionLabel={[Function]}
id="mock-list-control"
@ -194,7 +192,6 @@ exports[`Renders list control 1`] = `
>
<InjectIntl(ListControlUi)
controlIndex={0}
disableMsg={null}
fetchOptions={[Function]}
formatOptionLabel={[Function]}
id="mock-list-control"

View file

@ -24,6 +24,7 @@ exports[`renders ListControl 1`] = `
label="list control"
>
<EuiComboBox
async={false}
compressed={false}
data-test-subj="listControlSelect0"
fullWidth={false}

View file

@ -20,7 +20,6 @@ exports[`disabled 1`] = `
exports[`renders RangeControl 1`] = `
<FormRow
controlIndex={0}
disableMsg={null}
id="mock-range-control"
label="range control"
>

View file

@ -17,16 +17,24 @@
* under the License.
*/
import PropTypes from 'prop-types';
import React from 'react';
import React, { ReactElement } from 'react';
import { EuiFormRow, EuiToolTip, EuiIcon } from '@elastic/eui';
export function FormRow(props) {
export interface FormRowProps {
label: string;
warningMsg?: string;
id: string;
children: ReactElement;
controlIndex: number;
disableMsg?: string;
}
export function FormRow(props: FormRowProps) {
let control = props.children;
if (props.disableMsg) {
control = (
<EuiToolTip placement="top" content={props.disableMsg} anchorClassName="eui-displayBlock">
<EuiToolTip position="top" content={props.disableMsg} anchorClassName="eui-displayBlock">
{control}
</EuiToolTip>
);
@ -49,12 +57,3 @@ export function FormRow(props) {
</EuiFormRow>
);
}
FormRow.propTypes = {
label: PropTypes.string.isRequired,
warningMsg: PropTypes.string,
id: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
controlIndex: PropTypes.number.isRequired,
disableMsg: PropTypes.string,
};

View file

@ -21,11 +21,16 @@ import React from 'react';
import sinon from 'sinon';
import { shallow } from 'enzyme';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
// @ts-ignore
import { findTestSubject } from '@elastic/eui/lib/test';
import { InputControlVis } from './input_control_vis';
import { ListControl } from '../../control/list_control_factory';
import { RangeControl } from '../../control/range_control_factory';
const mockListControl = {
jest.mock('ui/new_platform');
const mockListControl: ListControl = {
id: 'mock-list-control',
isEnabled: () => {
return true;
@ -38,11 +43,9 @@ const mockListControl = {
label: 'list control',
value: [],
selectOptions: ['choice1', 'choice2'],
format: value => {
return value;
},
};
const mockRangeControl = {
format: (value: any) => value,
} as ListControl;
const mockRangeControl: RangeControl = {
id: 'mock-range-control',
isEnabled: () => {
return true;
@ -56,16 +59,16 @@ const mockRangeControl = {
value: { min: 0, max: 0 },
min: 0,
max: 100,
format: value => {
return value;
},
};
format: (value: any) => value,
} as RangeControl;
const updateFiltersOnChange = false;
let stageFilter;
let submitFilters;
let resetControls;
let clearControls;
const refreshControlMock = () => Promise.resolve();
let stageFilter: sinon.SinonSpy;
let submitFilters: sinon.SinonSpy;
let resetControls: sinon.SinonSpy;
let clearControls: sinon.SinonSpy;
beforeEach(() => {
stageFilter = sinon.spy();
@ -89,7 +92,7 @@ test('Renders list control', () => {
hasValues={() => {
return false;
}}
refreshControl={() => {}}
refreshControl={refreshControlMock}
/>
);
expect(component).toMatchSnapshot(); // eslint-disable-line
@ -110,7 +113,7 @@ test('Renders range control', () => {
hasValues={() => {
return false;
}}
refreshControl={() => {}}
refreshControl={refreshControlMock}
/>
);
expect(component).toMatchSnapshot(); // eslint-disable-line
@ -131,7 +134,7 @@ test('Apply and Cancel change btns enabled when there are changes', () => {
hasValues={() => {
return false;
}}
refreshControl={() => {}}
refreshControl={refreshControlMock}
/>
);
expect(component).toMatchSnapshot(); // eslint-disable-line
@ -152,7 +155,7 @@ test('Clear btns enabled when there are values', () => {
hasValues={() => {
return true;
}}
refreshControl={() => {}}
refreshControl={refreshControlMock}
/>
);
expect(component).toMatchSnapshot(); // eslint-disable-line
@ -173,7 +176,7 @@ test('clearControls', () => {
hasValues={() => {
return true;
}}
refreshControl={() => {}}
refreshControl={refreshControlMock}
/>
);
findTestSubject(component, 'inputControlClearBtn').simulate('click');
@ -198,7 +201,7 @@ test('submitFilters', () => {
hasValues={() => {
return true;
}}
refreshControl={() => {}}
refreshControl={refreshControlMock}
/>
);
findTestSubject(component, 'inputControlSubmitBtn').simulate('click');
@ -223,7 +226,7 @@ test('resetControls', () => {
hasValues={() => {
return true;
}}
refreshControl={() => {}}
refreshControl={refreshControlMock}
/>
);
findTestSubject(component, 'inputControlCancelBtn').simulate('click');

View file

@ -17,16 +17,37 @@
* under the License.
*/
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { RangeControl } from './range_control';
import { ListControl } from './list_control';
import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { CONTROL_TYPES } from '../../editor_utils';
import { ListControl } from '../../control/list_control_factory';
import { RangeControl } from '../../control/range_control_factory';
import { ListControl as ListControlComponent } from '../vis/list_control';
import { RangeControl as RangeControlComponent } from '../vis/range_control';
export class InputControlVis extends Component {
constructor(props) {
function isListControl(control: RangeControl | ListControl): control is ListControl {
return control.type === CONTROL_TYPES.LIST;
}
function isRangeControl(control: RangeControl | ListControl): control is RangeControl {
return control.type === CONTROL_TYPES.RANGE;
}
interface InputControlVisProps {
stageFilter: (controlIndex: number, newValue: any) => void;
submitFilters: () => void;
resetControls: () => void;
clearControls: () => void;
controls: Array<RangeControl | ListControl>;
updateFiltersOnChange?: boolean;
hasChanges: () => boolean;
hasValues: () => boolean;
refreshControl: (controlIndex: number, query: any) => Promise<void>;
}
export class InputControlVis extends Component<InputControlVisProps> {
constructor(props: InputControlVisProps) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
@ -49,39 +70,38 @@ export class InputControlVis extends Component {
renderControls() {
return this.props.controls.map((control, index) => {
let controlComponent = null;
switch (control.type) {
case 'list':
controlComponent = (
<ListControl
id={control.id}
label={control.label}
options={control.selectOptions}
selectedOptions={control.value}
formatOptionLabel={control.format}
disableMsg={control.isEnabled() ? null : control.disabledReason}
multiselect={control.options.multiselect}
partialResults={control.partialResults}
dynamicOptions={control.options.dynamicOptions}
controlIndex={index}
stageFilter={this.props.stageFilter}
fetchOptions={query => {
this.props.refreshControl(index, query);
}}
/>
);
break;
case 'range':
controlComponent = (
<RangeControl
control={control}
controlIndex={index}
stageFilter={this.props.stageFilter}
/>
);
break;
default:
throw new Error(`Unhandled control type ${control.type}`);
if (isListControl(control)) {
controlComponent = (
<ListControlComponent
id={control.id}
label={control.label}
options={control.selectOptions}
selectedOptions={control.value}
formatOptionLabel={control.format}
disableMsg={control.isEnabled() ? undefined : control.disabledReason}
multiselect={control.options.multiselect}
partialResults={control.partialResults}
dynamicOptions={control.options.dynamicOptions}
controlIndex={index}
stageFilter={this.props.stageFilter}
fetchOptions={query => {
this.props.refreshControl(index, query);
}}
/>
);
} else if (isRangeControl(control)) {
controlComponent = (
<RangeControlComponent
control={control}
controlIndex={index}
stageFilter={this.props.stageFilter}
/>
);
} else {
throw new Error(`Unhandled control type ${control!.type}`);
}
return (
<EuiFlexItem
key={control.id}
@ -152,15 +172,3 @@ export class InputControlVis extends Component {
);
}
}
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,
refreshControl: PropTypes.func.isRequired,
};

View file

@ -25,11 +25,11 @@ import { ListControl } from './list_control';
const options = ['choice1', 'choice2'];
const formatOptionLabel = value => {
const formatOptionLabel = (value: any) => {
return `${value} + formatting`;
};
let stageFilter;
let stageFilter: sinon.SinonSpy;
beforeEach(() => {
stageFilter = sinon.spy();
@ -46,6 +46,7 @@ test('renders ListControl', () => {
controlIndex={0}
stageFilter={stageFilter}
formatOptionLabel={formatOptionLabel}
intl={{} as any}
/>
);
expect(component).toMatchSnapshot(); // eslint-disable-line
@ -56,11 +57,13 @@ test('disableMsg', () => {
<ListControl.WrappedComponent
id="mock-list-control"
label="list control"
selectedOptions={[]}
multiselect={true}
controlIndex={0}
stageFilter={stageFilter}
formatOptionLabel={formatOptionLabel}
disableMsg={'control is disabled to test rendering when disabled'}
intl={{} as any}
/>
);
expect(component).toMatchSnapshot(); // eslint-disable-line

View file

@ -17,46 +17,76 @@
* under the License.
*/
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import React, { PureComponent } from 'react';
import _ from 'lodash';
import { FormRow } from './form_row';
import { injectI18n } from '@kbn/i18n/react';
import { InjectedIntlProps } from 'react-intl';
import { EuiFieldText, EuiComboBox } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormRow } from './form_row';
interface ListControlUiState {
isLoading: boolean;
}
export type ListControlUiProps = InjectedIntlProps & {
id: string;
label: string;
selectedOptions: any[];
options?: any[];
formatOptionLabel: (option: any) => any;
disableMsg?: string;
multiselect?: boolean;
dynamicOptions?: boolean;
partialResults?: boolean;
controlIndex: number;
stageFilter: (controlIndex: number, value: any) => void;
fetchOptions?: (searchValue: string) => void;
};
class ListControlUi extends PureComponent<ListControlUiProps, ListControlUiState> {
static defaultProps = {
dynamicOptions: false,
multiselect: true,
selectedOptions: [],
options: [],
};
private isMounted: boolean = false;
class ListControlUi extends Component {
state = {
isLoading: false,
};
componentDidMount = () => {
this._isMounted = true;
this.isMounted = true;
};
componentWillUnmount = () => {
this._isMounted = false;
this.isMounted = false;
};
handleOnChange = selectedOptions => {
handleOnChange = (selectedOptions: any[]) => {
const selectedValues = selectedOptions.map(({ value }) => {
return value;
});
this.props.stageFilter(this.props.controlIndex, selectedValues);
};
debouncedFetch = _.debounce(async searchValue => {
await this.props.fetchOptions(searchValue);
debouncedFetch = _.debounce(async (searchValue: string) => {
if (this.props.fetchOptions) {
await this.props.fetchOptions(searchValue);
}
if (this._isMounted) {
if (this.isMounted) {
this.setState({
isLoading: false,
});
}
}, 300);
onSearchChange = searchValue => {
onSearchChange = (searchValue: string) => {
this.setState(
{
isLoading: true,
@ -81,7 +111,7 @@ class ListControlUi extends Component {
}
const options = this.props.options
.map(option => {
?.map(option => {
return {
label: this.props.formatOptionLabel(option).toString(),
value: option,
@ -141,29 +171,4 @@ class ListControlUi extends Component {
}
}
ListControlUi.propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
selectedOptions: PropTypes.array.isRequired,
options: PropTypes.array,
formatOptionLabel: PropTypes.func.isRequired,
disableMsg: PropTypes.string,
multiselect: PropTypes.bool,
dynamicOptions: PropTypes.bool,
partialResults: PropTypes.bool,
controlIndex: PropTypes.number.isRequired,
stageFilter: PropTypes.func.isRequired,
fetchOptions: PropTypes.func,
};
ListControlUi.defaultProps = {
dynamicOptions: false,
multiselect: true,
};
ListControlUi.defaultProps = {
selectedOptions: [],
options: [],
};
export const ListControl = injectI18n(ListControlUi);

View file

@ -21,8 +21,11 @@ import React from 'react';
import { shallowWithIntl } from 'test_utils/enzyme_helpers';
import { RangeControl, ceilWithPrecision, floorWithPrecision } from './range_control';
import { RangeControl as RangeControlClass } from '../../control/range_control_factory';
const control = {
jest.mock('ui/new_platform');
const control: RangeControlClass = {
id: 'mock-range-control',
isEnabled: () => {
return true;
@ -39,7 +42,7 @@ const control = {
hasValue: () => {
return false;
},
};
} as RangeControlClass;
test('renders RangeControl', () => {
const component = shallowWithIntl(
@ -49,7 +52,7 @@ test('renders RangeControl', () => {
});
test('disabled', () => {
const disabledRangeControl = {
const disabledRangeControl: RangeControlClass = {
id: 'mock-range-control',
isEnabled: () => {
return false;
@ -64,7 +67,7 @@ test('disabled', () => {
hasValue: () => {
return false;
},
};
} as RangeControlClass;
const component = shallowWithIntl(
<RangeControl control={disabledRangeControl} controlIndex={0} stageFilter={() => {}} />
);

View file

@ -18,12 +18,17 @@
*/
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { FormRow } from './form_row';
import { ValidatedDualRange } from 'ui/validated_range';
import React, { PureComponent } from 'react';
function roundWithPrecision(value, decimalPlaces, roundFunction) {
import { ValidatedDualRange } from '../../legacy_imports';
import { FormRow } from './form_row';
import { RangeControl as RangeControlClass } from '../../control/range_control_factory';
function roundWithPrecision(
value: number,
decimalPlaces: number,
roundFunction: (n: number) => number
) {
if (decimalPlaces <= 0) {
return roundFunction(value);
}
@ -35,18 +40,29 @@ function roundWithPrecision(value, decimalPlaces, roundFunction) {
return results;
}
export function ceilWithPrecision(value, decimalPlaces) {
export function ceilWithPrecision(value: number, decimalPlaces: number) {
return roundWithPrecision(value, decimalPlaces, Math.ceil);
}
export function floorWithPrecision(value, decimalPlaces) {
export function floorWithPrecision(value: number, decimalPlaces: number) {
return roundWithPrecision(value, decimalPlaces, Math.floor);
}
export class RangeControl extends Component {
state = {};
export interface RangeControlState {
value?: [string, string];
prevValue?: [string, string];
}
static getDerivedStateFromProps(nextProps, prevState) {
export interface RangeControlProps {
control: RangeControlClass;
controlIndex: number;
stageFilter: (controlIndex: number, value: any) => void;
}
export class RangeControl extends PureComponent<RangeControlProps, RangeControlState> {
state: RangeControlState = {};
static getDerivedStateFromProps(nextProps: RangeControlProps, prevState: RangeControlState) {
const nextValue = nextProps.control.hasValue()
? [nextProps.control.value.min, nextProps.control.value.max]
: ['', ''];
@ -68,7 +84,7 @@ export class RangeControl extends Component {
return null;
}
onChangeComplete = _.debounce(value => {
onChangeComplete = _.debounce((value: [string, string]) => {
const controlValue = {
min: value[0],
max: value[1],
@ -111,16 +127,10 @@ export class RangeControl extends Component {
id={this.props.control.id}
label={this.props.control.label}
controlIndex={this.props.controlIndex}
disableMsg={this.props.control.isEnabled() ? null : this.props.control.disabledReason}
disableMsg={this.props.control.isEnabled() ? undefined : this.props.control.disabledReason}
>
{this.renderControl()}
</FormRow>
);
}
}
RangeControl.propTypes = {
control: PropTypes.object.isRequired,
controlIndex: PropTypes.number.isRequired,
stageFilter: PropTypes.func.isRequired,
};

View file

@ -19,34 +19,50 @@
import expect from '@kbn/expect';
import { Control } from './control';
import { ControlParams } from '../editor_utils';
import { FilterManager as BaseFilterManager } from './filter_manager/filter_manager';
import { SearchSource } from '../legacy_imports';
function createControlParams(id, label) {
function createControlParams(id: string, label: string): ControlParams {
return {
id: id,
id,
options: {},
label: label,
};
label,
} as ControlParams;
}
let valueFromFilterBar;
const mockFilterManager = {
let valueFromFilterBar: any;
const mockFilterManager: BaseFilterManager = {
getValueFromFilterBar: () => {
return valueFromFilterBar;
},
createFilter: value => {
return `mockKbnFilter:${value}`;
createFilter: (value: any) => {
return `mockKbnFilter:${value}` as any;
},
getIndexPattern: () => {
return 'mockIndexPattern';
},
};
const mockKbnApi = {};
} as any;
class ControlMock extends Control<BaseFilterManager> {
fetch() {
return Promise.resolve();
}
destroy() {}
}
const mockKbnApi: SearchSource = {} as SearchSource;
describe('hasChanged', () => {
let control;
let control: ControlMock;
beforeEach(() => {
control = new Control(createControlParams(3, 'control'), mockFilterManager, mockKbnApi);
control = new ControlMock(
createControlParams('3', 'control'),
mockFilterManager,
false,
mockKbnApi
);
});
afterEach(() => {
@ -70,23 +86,26 @@ describe('hasChanged', () => {
});
describe('ancestors', () => {
let grandParentControl;
let parentControl;
let childControl;
let grandParentControl: any;
let parentControl: any;
let childControl: any;
beforeEach(() => {
grandParentControl = new Control(
createControlParams(1, 'grandparent control'),
grandParentControl = new ControlMock(
createControlParams('1', 'grandparent control'),
mockFilterManager,
false,
mockKbnApi
);
parentControl = new Control(
createControlParams(2, 'parent control'),
parentControl = new ControlMock(
createControlParams('2', 'parent control'),
mockFilterManager,
false,
mockKbnApi
);
childControl = new Control(
createControlParams(3, 'child control'),
childControl = new ControlMock(
createControlParams('3', 'child control'),
mockFilterManager,
false,
mockKbnApi
);
});
@ -122,7 +141,7 @@ describe('ancestors', () => {
});
describe('getAncestorValues', () => {
let lastAncestorValues;
let lastAncestorValues: any[];
beforeEach(() => {
grandParentControl.set('myGrandParentValue');
parentControl.set('myParentValue');

View file

@ -22,32 +22,53 @@
import _ from 'lodash';
import { i18n } from '@kbn/i18n';
export function noValuesDisableMsg(fieldName, indexPatternName) {
import { esFilters } from '../../../../../plugins/data/public';
import { SearchSource as SearchSourceClass } from '../legacy_imports';
import { ControlParams, ControlParamsOptions, CONTROL_TYPES } from '../editor_utils';
import { RangeFilterManager } from './filter_manager/range_filter_manager';
import { PhraseFilterManager } from './filter_manager/phrase_filter_manager';
import { FilterManager as BaseFilterManager } from './filter_manager/filter_manager';
export function noValuesDisableMsg(fieldName: string, indexPatternName: string) {
return i18n.translate('inputControl.control.noValuesDisableTooltip', {
defaultMessage:
'Filtering occurs on the "{fieldName}" field, which doesn\'t exist on any documents in the "{indexPatternName}" \
index pattern. Choose a different field or index documents that contain values for this field.',
values: { fieldName: fieldName, indexPatternName: indexPatternName },
values: { fieldName, indexPatternName },
});
}
export function noIndexPatternMsg(indexPatternId) {
export function noIndexPatternMsg(indexPatternId: string) {
return i18n.translate('inputControl.control.noIndexPatternTooltip', {
defaultMessage: 'Could not locate index-pattern id: {indexPatternId}.',
values: { indexPatternId },
});
}
export class Control {
constructor(controlParams, filterManager, useTimeFilter, SearchSource) {
export abstract class Control<FilterManager extends BaseFilterManager> {
private kbnFilter: esFilters.Filter | null = null;
enable: boolean = false;
disabledReason: string = '';
value: any;
id: string;
options: ControlParamsOptions;
type: CONTROL_TYPES;
label: string;
ancestors: Array<Control<PhraseFilterManager | RangeFilterManager>> = [];
constructor(
public controlParams: ControlParams,
public filterManager: FilterManager,
public useTimeFilter: boolean,
public SearchSource: SearchSourceClass
) {
this.id = controlParams.id;
this.controlParams = controlParams;
this.options = controlParams.options;
this.type = controlParams.type;
this.label = controlParams.label ? controlParams.label : controlParams.fieldName;
this.useTimeFilter = useTimeFilter;
this.filterManager = filterManager;
this.SearchSource = SearchSource;
// restore state from kibana filter context
this.reset();
@ -59,28 +80,20 @@ export class Control {
);
}
async fetch() {
throw new Error('fetch method not defined, subclass are required to implement');
}
abstract fetch(query: string): Promise<void>;
destroy() {
throw new Error('destroy method not defined, subclass are required to implement');
}
abstract destroy(): void;
format = value => {
format = (value: any) => {
const field = this.filterManager.getField();
if (field) {
if (field?.format?.convert) {
return field.format.convert(value);
}
return value;
};
/**
*
* @param ancestors {array of Controls}
*/
setAncestors(ancestors) {
setAncestors(ancestors: Array<Control<PhraseFilterManager | RangeFilterManager>>) {
this.ancestors = ancestors;
}
@ -110,17 +123,17 @@ export class Control {
return this.enable;
}
disable(reason) {
disable(reason: string) {
this.enable = false;
this.disabledReason = reason;
}
set(newValue) {
set(newValue: any) {
this.value = newValue;
if (this.hasValue()) {
this._kbnFilter = this.filterManager.createFilter(this.value);
this.kbnFilter = this.filterManager.createFilter(this.value);
} else {
this._kbnFilter = null;
this.kbnFilter = null;
}
}
@ -128,7 +141,7 @@ export class Control {
* Remove any user changes to value by resetting value to that as provided by Kibana filter pills
*/
reset() {
this._kbnFilter = null;
this.kbnFilter = null;
this.value = this.filterManager.getValueFromFilterBar();
}
@ -144,17 +157,17 @@ export class Control {
}
hasKbnFilter() {
if (this._kbnFilter) {
if (this.kbnFilter) {
return true;
}
return false;
}
getKbnFilter() {
return this._kbnFilter;
return this.kbnFilter;
}
hasValue() {
hasValue(): boolean {
return this.value !== undefined;
}
}

View file

@ -19,14 +19,15 @@
import { rangeControlFactory } from './range_control_factory';
import { listControlFactory } from './list_control_factory';
import { ControlParams, CONTROL_TYPES } from '../editor_utils';
export function controlFactory(controlParams) {
export function getControlFactory(controlParams: ControlParams) {
let factory = null;
switch (controlParams.type) {
case 'range':
case CONTROL_TYPES.RANGE:
factory = rangeControlFactory;
break;
case 'list':
case CONTROL_TYPES.LIST:
factory = listControlFactory;
break;
default:

View file

@ -16,15 +16,18 @@
* specific language governing permissions and limitations
* under the License.
*/
import { timefilter } from 'ui/timefilter';
import { esFilters, IndexPattern, TimefilterSetup } from '../../../../../plugins/data/public';
import { SearchSource as SearchSourceClass, SearchSourceFields } from '../legacy_imports';
export function createSearchSource(
SearchSource,
initialState,
indexPattern,
aggs,
useTimeFilter,
filters = []
SearchSource: SearchSourceClass,
initialState: SearchSourceFields | null,
indexPattern: IndexPattern,
aggs: any,
useTimeFilter: boolean,
filters: esFilters.PhraseFilter[] = [],
timefilter: TimefilterSetup['timefilter']
) {
const searchSource = initialState ? new SearchSource(initialState) : new SearchSource();
// Do not not inherit from rootSearchSource to avoid picking up time and globals
@ -32,7 +35,10 @@ export function createSearchSource(
searchSource.setField('filter', () => {
const activeFilters = [...filters];
if (useTimeFilter) {
activeFilters.push(timefilter.createFilter(indexPattern));
const filter = timefilter.createFilter(indexPattern);
if (filter) {
activeFilters.push(filter);
}
}
return activeFilters;
});

View file

@ -18,30 +18,45 @@
*/
import expect from '@kbn/expect';
import { FilterManager } from './filter_manager';
import { coreMock } from '../../../../../../core/public/mocks';
import {
esFilters,
IndexPattern,
FilterManager as QueryFilterManager,
} from '../../../../../../plugins/data/public';
const setupMock = coreMock.createSetup();
class FilterManagerTest extends FilterManager {
createFilter() {
return {} as esFilters.Filter;
}
getValueFromFilterBar() {
return null;
}
}
describe('FilterManager', function() {
const controlId = 'control1';
describe('findFilters', function() {
const indexPatternMock = {};
let kbnFilters;
const queryFilterMock = {
getAppFilters: () => {
return kbnFilters;
},
getGlobalFilters: () => {
return [];
},
};
let filterManager;
const indexPatternMock = {} as IndexPattern;
let kbnFilters: esFilters.Filter[];
const queryFilterMock = new QueryFilterManager(setupMock.uiSettings);
queryFilterMock.getAppFilters = () => kbnFilters;
queryFilterMock.getGlobalFilters = () => [];
let filterManager: FilterManagerTest;
beforeEach(() => {
kbnFilters = [];
filterManager = new FilterManager(controlId, 'field1', indexPatternMock, queryFilterMock);
filterManager = new FilterManagerTest(controlId, 'field1', indexPatternMock, queryFilterMock);
});
test('should not find filters that are not controlled by any visualization', function() {
kbnFilters.push({});
kbnFilters.push({} as esFilters.Filter);
const foundFilters = filterManager.findFilters();
expect(foundFilters.length).to.be(0);
});
@ -51,7 +66,7 @@ describe('FilterManager', function() {
meta: {
controlledBy: 'anotherControl',
},
});
} as esFilters.Filter);
const foundFilters = filterManager.findFilters();
expect(foundFilters.length).to.be(0);
});
@ -61,7 +76,7 @@ describe('FilterManager', function() {
meta: {
controlledBy: controlId,
},
});
} as esFilters.Filter);
const foundFilters = filterManager.findFilters();
expect(foundFilters.length).to.be(1);
});

View file

@ -19,15 +19,33 @@
import _ from 'lodash';
export class FilterManager {
constructor(controlId, fieldName, indexPattern, queryFilter) {
this.controlId = controlId;
this.fieldName = fieldName;
this.indexPattern = indexPattern;
this.queryFilter = queryFilter;
}
import {
FilterManager as QueryFilterManager,
IndexPattern,
esFilters,
} from '../../../../../../plugins/data/public';
getIndexPattern() {
export abstract class FilterManager {
constructor(
public controlId: string,
public fieldName: string,
public indexPattern: IndexPattern,
public queryFilter: QueryFilterManager
) {}
/**
* Convert phrases into filter
*
* @param {any[]} phrases
* @returns PhraseFilter
* single phrase: match query
* multiple phrases: bool query with should containing list of match_phrase queries
*/
abstract createFilter(phrases: any): esFilters.Filter;
abstract getValueFromFilterBar(): any;
getIndexPattern(): IndexPattern {
return this.indexPattern;
}
@ -35,11 +53,7 @@ export class FilterManager {
return this.indexPattern.fields.getByName(this.fieldName);
}
createFilter() {
throw new Error('Must implement createFilter.');
}
findFilters() {
findFilters(): esFilters.Filter[] {
const kbnFilters = _.flatten([
this.queryFilter.getAppFilters(),
this.queryFilter.getGlobalFilters(),
@ -48,8 +62,4 @@ export class FilterManager {
return _.get(kbnFilter, 'meta.controlledBy') === this.controlId;
});
}
getValueFromFilterBar() {
throw new Error('Must implement getValueFromFilterBar.');
}
}

View file

@ -18,6 +18,12 @@
*/
import expect from '@kbn/expect';
import {
esFilters,
IndexPattern,
FilterManager as QueryFilterManager,
} from '../../../../../../plugins/data/public';
import { PhraseFilterManager } from './phrase_filter_manager';
describe('PhraseFilterManager', function() {
@ -28,22 +34,20 @@ describe('PhraseFilterManager', function() {
const fieldMock = {
name: 'field1',
format: {
convert: val => {
return val;
},
convert: (value: any) => value,
},
};
const indexPatternMock = {
const indexPatternMock: IndexPattern = {
id: indexPatternId,
fields: {
getByName: name => {
const fields = { field1: fieldMock };
getByName: (name: string) => {
const fields: any = { field1: fieldMock };
return fields[name];
},
},
};
const queryFilterMock = {};
let filterManager;
} as IndexPattern;
const queryFilterMock: QueryFilterManager = {} as QueryFilterManager;
let filterManager: PhraseFilterManager;
beforeEach(() => {
filterManager = new PhraseFilterManager(
controlId,
@ -83,22 +87,32 @@ describe('PhraseFilterManager', function() {
});
describe('getValueFromFilterBar', function() {
const indexPatternMock = {};
const queryFilterMock = {};
let filterManager;
beforeEach(() => {
class MockFindFiltersPhraseFilterManager extends PhraseFilterManager {
constructor(controlId, fieldName, indexPattern, queryFilter, delimiter) {
super(controlId, fieldName, indexPattern, queryFilter, delimiter);
this.mockFilters = [];
}
findFilters() {
return this.mockFilters;
}
setMockFilters(mockFilters) {
this.mockFilters = mockFilters;
}
class MockFindFiltersPhraseFilterManager extends PhraseFilterManager {
mockFilters: esFilters.Filter[];
constructor(
id: string,
fieldName: string,
indexPattern: IndexPattern,
queryFilter: QueryFilterManager
) {
super(id, fieldName, indexPattern, queryFilter);
this.mockFilters = [];
}
findFilters() {
return this.mockFilters;
}
setMockFilters(mockFilters: esFilters.Filter[]) {
this.mockFilters = mockFilters;
}
}
const indexPatternMock: IndexPattern = {} as IndexPattern;
const queryFilterMock: QueryFilterManager = {} as QueryFilterManager;
let filterManager: MockFindFiltersPhraseFilterManager;
beforeEach(() => {
filterManager = new MockFindFiltersPhraseFilterManager(
controlId,
'field1',
@ -119,7 +133,7 @@ describe('PhraseFilterManager', function() {
},
},
},
]);
] as esFilters.Filter[]);
expect(filterManager.getValueFromFilterBar()).to.eql(['ios']);
});
@ -145,7 +159,7 @@ describe('PhraseFilterManager', function() {
},
},
},
]);
] as esFilters.Filter[]);
expect(filterManager.getValueFromFilterBar()).to.eql(['ios', 'win xp']);
});
@ -169,7 +183,7 @@ describe('PhraseFilterManager', function() {
},
},
},
]);
] as esFilters.Filter[]);
expect(filterManager.getValueFromFilterBar()).to.eql(['ios', 'win xp']);
});
@ -185,7 +199,7 @@ describe('PhraseFilterManager', function() {
},
},
},
]);
] as esFilters.Filter[]);
expect(filterManager.getValueFromFilterBar()).to.eql(undefined);
});
});

View file

@ -18,37 +18,38 @@
*/
import _ from 'lodash';
import { FilterManager } from './filter_manager.js';
import { esFilters } from '../../../../../../plugins/data/public';
import { FilterManager } from './filter_manager';
import {
esFilters,
IndexPattern,
FilterManager as QueryFilterManager,
} from '../../../../../../plugins/data/public';
export class PhraseFilterManager extends FilterManager {
constructor(controlId, fieldName, indexPattern, queryFilter) {
constructor(
controlId: string,
fieldName: string,
indexPattern: IndexPattern,
queryFilter: QueryFilterManager
) {
super(controlId, fieldName, indexPattern, queryFilter);
}
/**
* Convert phrases into filter
*
* @param {array} phrases
* @return {object} query filter
* single phrase: match query
* multiple phrases: bool query with should containing list of match_phrase queries
*/
createFilter(phrases) {
let newFilter;
if (phrases.length === 1) {
newFilter = esFilters.buildPhraseFilter(
this.indexPattern.fields.getByName(this.fieldName),
phrases[0],
this.indexPattern
);
} else {
newFilter = esFilters.buildPhrasesFilter(
this.indexPattern.fields.getByName(this.fieldName),
phrases,
this.indexPattern
);
createFilter(phrases: any): esFilters.PhraseFilter {
let newFilter: esFilters.PhraseFilter;
const value = this.indexPattern.fields.getByName(this.fieldName);
if (!value) {
throw new Error(`Unable to find field with name: ${this.fieldName} on indexPattern`);
}
if (phrases.length === 1) {
newFilter = esFilters.buildPhraseFilter(value, phrases[0], this.indexPattern);
} else {
newFilter = esFilters.buildPhrasesFilter(value, phrases, this.indexPattern);
}
newFilter.meta.key = this.fieldName;
newFilter.meta.controlledBy = this.controlId;
return newFilter;
@ -62,7 +63,7 @@ export class PhraseFilterManager extends FilterManager {
const values = kbnFilters
.map(kbnFilter => {
return this._getValueFromFilter(kbnFilter);
return this.getValueFromFilter(kbnFilter);
})
.filter(value => value != null);
@ -78,15 +79,15 @@ export class PhraseFilterManager extends FilterManager {
/**
* Extract filtering value from kibana filters
*
* @param {object} kbnFilter
* @param {esFilters.PhraseFilter} kbnFilter
* @return {Array.<string>} array of values pulled from filter
*/
_getValueFromFilter(kbnFilter) {
private getValueFromFilter(kbnFilter: esFilters.PhraseFilter): any {
// bool filter - multiple phrase filters
if (_.has(kbnFilter, 'query.bool.should')) {
return _.get(kbnFilter, 'query.bool.should')
.map(kbnFilter => {
return this._getValueFromFilter(kbnFilter);
return _.get<esFilters.PhraseFilter[]>(kbnFilter, 'query.bool.should')
.map(kbnQueryFilter => {
return this.getValueFromFilter(kbnQueryFilter);
})
.filter(value => {
if (value) {

View file

@ -18,7 +18,13 @@
*/
import expect from '@kbn/expect';
import { RangeFilterManager } from './range_filter_manager';
import {
esFilters,
IndexPattern,
FilterManager as QueryFilterManager,
} from '../../../../../../plugins/data/public';
describe('RangeFilterManager', function() {
const controlId = 'control1';
@ -28,19 +34,19 @@ describe('RangeFilterManager', function() {
const fieldMock = {
name: 'field1',
};
const indexPatternMock = {
const indexPatternMock: IndexPattern = {
id: indexPatternId,
fields: {
getByName: name => {
const fields = {
getByName: (name: any) => {
const fields: any = {
field1: fieldMock,
};
return fields[name];
},
},
};
const queryFilterMock = {};
let filterManager;
} as IndexPattern;
const queryFilterMock: QueryFilterManager = {} as QueryFilterManager;
let filterManager: RangeFilterManager;
beforeEach(() => {
filterManager = new RangeFilterManager(
controlId,
@ -62,22 +68,32 @@ describe('RangeFilterManager', function() {
});
describe('getValueFromFilterBar', function() {
const indexPatternMock = {};
const queryFilterMock = {};
let filterManager;
beforeEach(() => {
class MockFindFiltersRangeFilterManager extends RangeFilterManager {
constructor(controlId, fieldName, indexPattern, queryFilter) {
super(controlId, fieldName, indexPattern, queryFilter);
this.mockFilters = [];
}
findFilters() {
return this.mockFilters;
}
setMockFilters(mockFilters) {
this.mockFilters = mockFilters;
}
class MockFindFiltersRangeFilterManager extends RangeFilterManager {
mockFilters: esFilters.RangeFilter[];
constructor(
id: string,
fieldName: string,
indexPattern: IndexPattern,
queryFilter: QueryFilterManager
) {
super(id, fieldName, indexPattern, queryFilter);
this.mockFilters = [];
}
findFilters() {
return this.mockFilters;
}
setMockFilters(mockFilters: esFilters.RangeFilter[]) {
this.mockFilters = mockFilters;
}
}
const indexPatternMock: IndexPattern = {} as IndexPattern;
const queryFilterMock: QueryFilterManager = {} as QueryFilterManager;
let filterManager: MockFindFiltersRangeFilterManager;
beforeEach(() => {
filterManager = new MockFindFiltersRangeFilterManager(
controlId,
'field1',
@ -95,14 +111,15 @@ describe('RangeFilterManager', function() {
lt: 3,
},
},
meta: {} as esFilters.RangeFilterMeta,
},
]);
] as esFilters.RangeFilter[]);
const value = filterManager.getValueFromFilterBar();
expect(value).to.be.a('object');
expect(value).to.have.property('min');
expect(value.min).to.be(1);
expect(value?.min).to.be(1);
expect(value).to.have.property('max');
expect(value.max).to.be(3);
expect(value?.max).to.be(3);
});
test('should return undefined when filter value can not be extracted from Kibana filter', function() {
@ -114,8 +131,9 @@ describe('RangeFilterManager', function() {
lte: 3,
},
},
meta: {} as esFilters.RangeFilterMeta,
},
]);
] as esFilters.RangeFilter[]);
expect(filterManager.getValueFromFilterBar()).to.eql(undefined);
});
});

View file

@ -18,11 +18,17 @@
*/
import _ from 'lodash';
import { FilterManager } from './filter_manager.js';
import { esFilters } from '../../../../../../plugins/data/public';
import { FilterManager } from './filter_manager';
import { esFilters, IFieldType } from '../../../../../../plugins/data/public';
interface SliderValue {
min?: string | number;
max?: string | number;
}
// Convert slider value into ES range filter
function toRange(sliderValue) {
function toRange(sliderValue: SliderValue) {
return {
gte: sliderValue.min,
lte: sliderValue.max,
@ -30,8 +36,8 @@ function toRange(sliderValue) {
}
// Convert ES range filter into slider value
function fromRange(range) {
const sliderValue = {};
function fromRange(range: esFilters.RangeFilterParams): SliderValue {
const sliderValue: SliderValue = {};
if (_.has(range, 'gte')) {
sliderValue.min = _.get(range, 'gte');
}
@ -54,9 +60,10 @@ export class RangeFilterManager extends FilterManager {
* @param {object} react-input-range value - POJO with `min` and `max` properties
* @return {object} range filter
*/
createFilter(value) {
createFilter(value: SliderValue): esFilters.RangeFilter {
const newFilter = esFilters.buildRangeFilter(
this.indexPattern.fields.getByName(this.fieldName),
// TODO: Fix type to be required
this.indexPattern.fields.getByName(this.fieldName) as IFieldType,
toRange(value),
this.indexPattern
);
@ -65,13 +72,13 @@ export class RangeFilterManager extends FilterManager {
return newFilter;
}
getValueFromFilterBar() {
getValueFromFilterBar(): SliderValue | undefined {
const kbnFilters = this.findFilters();
if (kbnFilters.length === 0) {
return;
}
let range;
let range: esFilters.RangeFilterParams;
if (_.has(kbnFilters[0], 'script')) {
range = _.get(kbnFilters[0], 'script.script.params');
} else {

View file

@ -17,92 +17,33 @@
* under the License.
*/
import chrome from 'ui/chrome';
import { listControlFactory } from './list_control_factory';
import { listControlFactory, ListControl } from './list_control_factory';
import { ControlParams, CONTROL_TYPES } from '../editor_utils';
import { getDepsMock } from '../components/editor/__tests__/get_deps_mock';
import { getSearchSourceMock } from '../components/editor/__tests__/get_search_service_mock';
jest.mock('ui/timefilter', () => ({
createFilter: jest.fn(),
const MockSearchSource = getSearchSourceMock();
const deps = getDepsMock();
jest.doMock('./create_search_source.ts', () => ({
createSearchSource: MockSearchSource,
}));
jest.mock('ui/new_platform', () => ({
npStart: {
plugins: {
data: {
query: {
filterManager: {
fieldName: 'myNumberField',
getIndexPattern: () => ({
fields: {
getByName: name => {
const fields = { myField: { name: 'myField' } };
return fields[name];
},
},
}),
getAppFilters: jest.fn().mockImplementation(() => []),
getGlobalFilters: jest.fn().mockImplementation(() => []),
},
},
indexPatterns: {
get: () => ({
fields: {
getByName: name => {
const fields = { myField: { name: 'myField' } };
return fields[name];
},
},
}),
},
},
},
},
}));
chrome.getInjected.mockImplementation(key => {
switch (key) {
case 'autocompleteTimeout':
return 1000;
case 'autocompleteTerminateAfter':
return 100000;
}
});
function MockSearchSource() {
return {
setParent: () => {},
setField: () => {},
fetch: async () => {
return {
aggregations: {
termsAgg: {
buckets: [
{
key: 'Zurich Airport',
doc_count: 691,
},
{
key: 'Xi an Xianyang International Airport',
doc_count: 526,
},
],
},
},
};
},
};
}
describe('hasValue', () => {
const controlParams = {
const controlParams: ControlParams = {
id: '1',
fieldName: 'myField',
options: {},
options: {} as any,
type: CONTROL_TYPES.LIST,
label: 'test',
indexPattern: {} as any,
parent: 'parent',
};
const useTimeFilter = false;
let listControl;
let listControl: ListControl;
beforeEach(async () => {
listControl = await listControlFactory(controlParams, useTimeFilter, MockSearchSource);
listControl = await listControlFactory(controlParams, useTimeFilter, MockSearchSource, deps);
});
test('should be false when control has no value', () => {
@ -121,22 +62,25 @@ describe('hasValue', () => {
});
describe('fetch', () => {
const controlParams = {
const controlParams: ControlParams = {
id: '1',
fieldName: 'myField',
options: {},
options: {} as any,
type: CONTROL_TYPES.LIST,
label: 'test',
indexPattern: {} as any,
parent: 'parent',
};
const useTimeFilter = false;
const SearchSource = jest.fn(MockSearchSource);
let listControl;
let listControl: ListControl;
beforeEach(async () => {
listControl = await listControlFactory(controlParams, useTimeFilter, SearchSource);
listControl = await listControlFactory(controlParams, useTimeFilter, MockSearchSource, deps);
});
test('should pass in timeout parameters from injected vars', async () => {
await listControl.fetch();
expect(SearchSource).toHaveBeenCalledWith({
expect(MockSearchSource).toHaveBeenCalledWith({
timeout: `1000ms`,
terminate_after: 100000,
});
@ -152,24 +96,37 @@ describe('fetch', () => {
});
describe('fetch with ancestors', () => {
const controlParams = {
const controlParams: ControlParams = {
id: '1',
fieldName: 'myField',
options: {},
options: {} as any,
type: CONTROL_TYPES.LIST,
label: 'test',
indexPattern: {} as any,
parent: 'parent',
};
const useTimeFilter = false;
let listControl;
let listControl: ListControl;
let parentControl;
beforeEach(async () => {
listControl = await listControlFactory(controlParams, useTimeFilter, MockSearchSource);
listControl = await listControlFactory(controlParams, useTimeFilter, MockSearchSource, deps);
const parentControlParams = {
const parentControlParams: ControlParams = {
id: 'parent',
fieldName: 'myField',
options: {},
options: {} as any,
type: CONTROL_TYPES.LIST,
label: 'test',
indexPattern: {} as any,
parent: 'parent',
};
parentControl = await listControlFactory(parentControlParams, useTimeFilter, MockSearchSource);
parentControl = await listControlFactory(
parentControlParams,
useTimeFilter,
MockSearchSource,
deps
);
parentControl.clear();
listControl.setAncestors([parentControl]);
});

View file

@ -18,20 +18,30 @@
*/
import _ from 'lodash';
import { i18n } from '@kbn/i18n';
import { SearchSource as SearchSourceClass, SearchSourceFields } from '../legacy_imports';
import { Control, noValuesDisableMsg, noIndexPatternMsg } from './control';
import { PhraseFilterManager } from './filter_manager/phrase_filter_manager';
import { createSearchSource } from './create_search_source';
import { i18n } from '@kbn/i18n';
import { npStart } from 'ui/new_platform';
import chrome from 'ui/chrome';
import { ControlParams } from '../editor_utils';
import { InputControlVisDependencies } from '../plugin';
import { IFieldType, TimefilterSetup } from '../../../../../plugins/data/public';
function getEscapedQuery(query = '') {
// https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-regexp-query.html#_standard_operators
return query.replace(/[.?+*|{}[\]()"\\#@&<>~]/g, match => `\\${match}`);
}
const termsAgg = ({ field, size, direction, query }) => {
const terms = {
interface TermsAggArgs {
field?: IFieldType;
size: number | null;
direction: string;
query?: string;
}
const termsAgg = ({ field, size, direction, query }: TermsAggArgs) => {
const terms: any = {
order: {
_count: direction,
},
@ -41,14 +51,14 @@ const termsAgg = ({ field, size, direction, query }) => {
terms.size = size < 1 ? 1 : size;
}
if (field.scripted) {
if (field?.scripted) {
terms.script = {
source: field.script,
lang: field.lang,
};
terms.value_type = field.type === 'number' ? 'float' : field.type;
} else {
terms.field = field.name;
terms.field = field?.name;
}
if (query) {
@ -57,13 +67,34 @@ const termsAgg = ({ field, size, direction, query }) => {
return {
termsAgg: {
terms: terms,
terms,
},
};
};
class ListControl extends Control {
fetch = async query => {
export class ListControl extends Control<PhraseFilterManager> {
private getInjectedVar: InputControlVisDependencies['core']['injectedMetadata']['getInjectedVar'];
private timefilter: TimefilterSetup['timefilter'];
abortController?: AbortController;
lastAncestorValues: any;
lastQuery?: string;
partialResults?: boolean;
selectOptions?: string[];
constructor(
controlParams: ControlParams,
filterManager: PhraseFilterManager,
useTimeFilter: boolean,
SearchSource: SearchSourceClass,
deps: InputControlVisDependencies
) {
super(controlParams, filterManager, useTimeFilter, SearchSource);
this.getInjectedVar = deps.core.injectedMetadata.getInjectedVar;
this.timefilter = deps.data.query.timefilter.timefilter;
}
fetch = async (query?: string) => {
// Abort any in-progress fetch
if (this.abortController) {
this.abortController.abort();
@ -101,9 +132,9 @@ class ListControl extends Control {
}
const fieldName = this.filterManager.fieldName;
const initialSearchSourceState = {
timeout: `${chrome.getInjected('autocompleteTimeout')}ms`,
terminate_after: chrome.getInjected('autocompleteTerminateAfter'),
const initialSearchSourceState: SearchSourceFields = {
timeout: `${this.getInjectedVar('autocompleteTimeout')}ms`,
terminate_after: Number(this.getInjectedVar('autocompleteTerminateAfter')),
};
const aggs = termsAgg({
field: indexPattern.fields.getByName(fieldName),
@ -117,7 +148,8 @@ class ListControl extends Control {
indexPattern,
aggs,
this.useTimeFilter,
ancestorFilters
ancestorFilters,
this.timefilter
);
const abortSignal = this.abortController.signal;
@ -143,8 +175,8 @@ class ListControl extends Control {
return;
}
const selectOptions = _.get(resp, 'aggregations.termsAgg.buckets', []).map(bucket => {
return bucket.key;
const selectOptions = _.get(resp, 'aggregations.termsAgg.buckets', []).map((bucket: any) => {
return bucket?.key;
});
if (selectOptions.length === 0 && !query) {
@ -167,29 +199,34 @@ class ListControl extends Control {
}
}
export async function listControlFactory(controlParams, useTimeFilter, SearchSource) {
let indexPattern;
try {
indexPattern = await npStart.plugins.data.indexPatterns.get(controlParams.indexPattern);
export async function listControlFactory(
controlParams: ControlParams,
useTimeFilter: boolean,
SearchSource: SearchSourceClass,
deps: InputControlVisDependencies
) {
const [, { data: dataPluginStart }] = await deps.core.getStartServices();
const indexPattern = await dataPluginStart.indexPatterns.get(controlParams.indexPattern);
// dynamic options are only allowed on String fields but the setting defaults to true so it could
// be enabled for non-string fields (since UI input is hidden for non-string fields).
// If field is not string, then disable dynamic options.
const field = indexPattern.fields.find(field => {
return field.name === controlParams.fieldName;
});
if (field && field.type !== 'string') {
controlParams.options.dynamicOptions = false;
}
} catch (err) {
// ignore not found error and return control so it can be displayed in disabled state.
// dynamic options are only allowed on String fields but the setting defaults to true so it could
// be enabled for non-string fields (since UI input is hidden for non-string fields).
// If field is not string, then disable dynamic options.
const field = indexPattern.fields.find(({ name }) => name === controlParams.fieldName);
if (field && field.type !== 'string') {
controlParams.options.dynamicOptions = false;
}
const { filterManager } = npStart.plugins.data.query;
return new ListControl(
const listControl = new ListControl(
controlParams,
new PhraseFilterManager(controlParams.id, controlParams.fieldName, indexPattern, filterManager),
new PhraseFilterManager(
controlParams.id,
controlParams.fieldName,
indexPattern,
deps.data.query.filterManager
),
useTimeFilter,
SearchSource
SearchSource,
deps
);
return listControl;
}

View file

@ -18,74 +18,37 @@
*/
import { rangeControlFactory } from './range_control_factory';
import { ControlParams, CONTROL_TYPES } from '../editor_utils';
import { getSearchSourceMock } from '../components/editor/__tests__/get_search_service_mock';
import { getDepsMock } from '../components/editor/__tests__/get_deps_mock';
let esSearchResponse;
class MockSearchSource {
setParent() {}
setField() {}
async fetch() {
return esSearchResponse;
}
}
jest.mock('ui/timefilter', () => ({
createFilter: jest.fn(),
}));
jest.mock('ui/new_platform', () => ({
npStart: {
plugins: {
data: {
query: {
filterManager: {
fieldName: 'myNumberField',
getIndexPattern: () => ({
fields: {
getByName: name => {
const fields = { myNumberField: { name: 'myNumberField' } };
return fields[name];
},
},
}),
getAppFilters: jest.fn().mockImplementation(() => []),
getGlobalFilters: jest.fn().mockImplementation(() => []),
},
},
indexPatterns: {
get: () => ({
fields: {
getByName: name => {
const fields = { myNumberField: { name: 'myNumberField' } };
return fields[name];
},
},
}),
},
},
},
},
}));
const deps = getDepsMock();
describe('fetch', () => {
const controlParams = {
const controlParams: ControlParams = {
id: '1',
fieldName: 'myNumberField',
options: {},
type: CONTROL_TYPES.RANGE,
label: 'test',
indexPattern: {} as any,
parent: {} as any,
};
const useTimeFilter = false;
let rangeControl;
beforeEach(async () => {
rangeControl = await rangeControlFactory(controlParams, useTimeFilter, MockSearchSource);
});
test('should set min and max from aggregation results', async () => {
esSearchResponse = {
const esSearchResponse = {
aggregations: {
maxAgg: { value: 100 },
minAgg: { value: 10 },
},
};
const rangeControl = await rangeControlFactory(
controlParams,
useTimeFilter,
getSearchSourceMock(esSearchResponse),
deps
);
await rangeControl.fetch();
expect(rangeControl.isEnabled()).toBe(true);
@ -95,12 +58,18 @@ describe('fetch', () => {
test('should disable control when there are 0 hits', async () => {
// ES response when the query does not match any documents
esSearchResponse = {
const esSearchResponse = {
aggregations: {
maxAgg: { value: null },
minAgg: { value: null },
},
};
const rangeControl = await rangeControlFactory(
controlParams,
useTimeFilter,
getSearchSourceMock(esSearchResponse),
deps
);
await rangeControl.fetch();
expect(rangeControl.isEnabled()).toBe(false);
@ -109,7 +78,13 @@ describe('fetch', () => {
test('should disable control when response is empty', async () => {
// ES response for dashboardonly user who does not have read permissions on index is 200 (which is weird)
// and there is not aggregations key
esSearchResponse = {};
const esSearchResponse = {};
const rangeControl = await rangeControlFactory(
controlParams,
useTimeFilter,
getSearchSourceMock(esSearchResponse),
deps
);
await rangeControl.fetch();
expect(rangeControl.isEnabled()).toBe(false);

View file

@ -18,22 +18,29 @@
*/
import _ from 'lodash';
import { i18n } from '@kbn/i18n';
import { SearchSource as SearchSourceClass } from '../legacy_imports';
import { Control, noValuesDisableMsg, noIndexPatternMsg } from './control';
import { RangeFilterManager } from './filter_manager/range_filter_manager';
import { createSearchSource } from './create_search_source';
import { i18n } from '@kbn/i18n';
import { npStart } from 'ui/new_platform';
import { ControlParams } from '../editor_utils';
import { InputControlVisDependencies } from '../plugin';
import { IFieldType, TimefilterSetup } from '../.../../../../../../plugins/data/public';
const minMaxAgg = field => {
const aggBody = {};
if (field.scripted) {
aggBody.script = {
source: field.script,
lang: field.lang,
};
} else {
aggBody.field = field.name;
const minMaxAgg = (field?: IFieldType) => {
const aggBody: any = {};
if (field) {
if (field.scripted) {
aggBody.script = {
source: field.script,
lang: field.lang,
};
} else {
aggBody.field = field.name;
}
}
return {
maxAgg: {
max: aggBody,
@ -44,7 +51,23 @@ const minMaxAgg = field => {
};
};
class RangeControl extends Control {
export class RangeControl extends Control<RangeFilterManager> {
timefilter: TimefilterSetup['timefilter'];
abortController: any;
min: any;
max: any;
constructor(
controlParams: ControlParams,
filterManager: RangeFilterManager,
useTimeFilter: boolean,
SearchSource: SearchSourceClass,
deps: InputControlVisDependencies
) {
super(controlParams, filterManager, useTimeFilter, SearchSource);
this.timefilter = deps.data.query.timefilter.timefilter;
}
async fetch() {
// Abort any in-progress fetch
if (this.abortController) {
@ -58,14 +81,15 @@ class RangeControl extends Control {
}
const fieldName = this.filterManager.fieldName;
const aggs = minMaxAgg(indexPattern.fields.getByName(fieldName));
const searchSource = createSearchSource(
this.SearchSource,
null,
indexPattern,
aggs,
this.useTimeFilter
this.useTimeFilter,
[],
this.timefilter
);
const abortSignal = this.abortController.signal;
@ -102,18 +126,25 @@ class RangeControl extends Control {
}
}
export async function rangeControlFactory(controlParams, useTimeFilter, SearchSource) {
let indexPattern;
try {
indexPattern = await npStart.plugins.data.indexPatterns.get(controlParams.indexPattern);
} catch (err) {
// ignore not found error and return control so it can be displayed in disabled state.
}
const { filterManager } = npStart.plugins.data.query;
export async function rangeControlFactory(
controlParams: ControlParams,
useTimeFilter: boolean,
SearchSource: SearchSourceClass,
deps: InputControlVisDependencies
): Promise<RangeControl> {
const [, { data: dataPluginStart }] = await deps.core.getStartServices();
const indexPattern = await dataPluginStart.indexPatterns.get(controlParams.indexPattern);
return new RangeControl(
controlParams,
new RangeFilterManager(controlParams.id, controlParams.fieldName, indexPattern, filterManager),
new RangeFilterManager(
controlParams.id,
controlParams.fieldName,
indexPattern,
deps.data.query.filterManager
),
useTimeFilter,
SearchSource
SearchSource,
deps
);
}

View file

@ -16,21 +16,54 @@
* specific language governing permissions and limitations
* under the License.
*/
import { $Values } from '@kbn/utility-types';
export const CONTROL_TYPES = {
LIST: 'list',
RANGE: 'range',
LIST: 'list' as 'list',
RANGE: 'range' as 'range',
};
export type CONTROL_TYPES = $Values<typeof CONTROL_TYPES>;
export const setControl = (controls, controlIndex, control) => [
export interface ControlParamsOptions {
decimalPlaces?: number;
step?: number;
type?: string;
multiselect?: boolean;
dynamicOptions?: boolean;
size?: number;
order?: string;
}
export interface ControlParams {
id: string;
type: CONTROL_TYPES;
label: string;
fieldName: string;
indexPattern: string;
parent: string;
options: ControlParamsOptions;
}
export const setControl = (
controls: ControlParams[],
controlIndex: number,
control: ControlParams
): ControlParams[] => [
...controls.slice(0, controlIndex),
control,
...controls.slice(controlIndex + 1),
];
export const addControl = (controls, control) => [...controls, control];
export const addControl = (controls: ControlParams[], control: ControlParams): ControlParams[] => [
...controls,
control,
];
export const moveControl = (controls, controlIndex, direction) => {
export const moveControl = (
controls: ControlParams[],
controlIndex: number,
direction: number
): ControlParams[] => {
let newIndex;
if (direction >= 0) {
newIndex = controlIndex + 1;
@ -54,13 +87,13 @@ export const moveControl = (controls, controlIndex, direction) => {
}
};
export const removeControl = (controls, controlIndex) => [
export const removeControl = (controls: ControlParams[], controlIndex: number): ControlParams[] => [
...controls.slice(0, controlIndex),
...controls.slice(controlIndex + 1),
];
export const getDefaultOptions = type => {
const defaultOptions = {};
export const getDefaultOptions = (type: CONTROL_TYPES): ControlParamsOptions => {
const defaultOptions: ControlParamsOptions = {};
switch (type) {
case CONTROL_TYPES.RANGE:
defaultOptions.decimalPlaces = 0;
@ -77,17 +110,17 @@ export const getDefaultOptions = type => {
return defaultOptions;
};
export const newControl = type => ({
export const newControl = (type: CONTROL_TYPES): ControlParams => ({
id: new Date().getTime().toString(),
indexPattern: '',
fieldName: '',
parent: '',
label: '',
type: type,
type,
options: getDefaultOptions(type),
});
export const getTitle = (controlParams, controlIndex) => {
export const getTitle = (controlParams: ControlParams, controlIndex: number): string => {
let title = `${controlParams.type}: ${controlIndex}`;
if (controlParams.label) {
title = `${controlParams.label}`;

View file

@ -17,14 +17,9 @@
* under the License.
*/
import { resolve } from 'path';
import { PluginInitializerContext } from '../../../../core/public';
import { InputControlVisPlugin as Plugin } from './plugin';
export default function(kibana) {
return new kibana.Plugin({
uiExports: {
visTypes: ['plugins/input_control_vis/register_vis'],
interpreter: ['plugins/input_control_vis/input_control_fn'],
styleSheetPaths: resolve(__dirname, 'public/index.scss'),
},
});
export function plugin(initializerContext: PluginInitializerContext) {
return new Plugin(initializerContext);
}

View file

@ -17,14 +17,15 @@
* under the License.
*/
jest.mock('ui/new_platform');
import { createInputControlVisFn } from './input_control_fn';
// eslint-disable-next-line
import { functionWrapper } from '../../../../plugins/expressions/public/functions/tests/utils';
import { inputControlVis } from './input_control_fn';
jest.mock('./legacy_imports.ts');
describe('interpreter/functions#input_control_vis', () => {
const fn = functionWrapper(inputControlVis);
const fn = functionWrapper(createInputControlVisFn);
const visConfig = {
controls: [
{
@ -47,8 +48,8 @@ describe('interpreter/functions#input_control_vis', () => {
pinFilters: false,
};
it('returns an object with the correct structure', () => {
const actual = fn(undefined, { visConfig: JSON.stringify(visConfig) });
it('returns an object with the correct structure', async () => {
const actual = await fn(null, { visConfig: JSON.stringify(visConfig) });
expect(actual).toMatchSnapshot();
});
});

View file

@ -17,10 +17,37 @@
* under the License.
*/
import { functionsRegistry } from 'plugins/interpreter/registries';
import { i18n } from '@kbn/i18n';
export const inputControlVis = () => ({
import {
ExpressionFunction,
KibanaDatatable,
Render,
} from '../../../../plugins/expressions/public';
const name = 'input_control_vis';
type Context = KibanaDatatable;
interface Arguments {
visConfig: string;
}
type VisParams = Required<Arguments>;
interface RenderValue {
visType: 'input_control_vis';
visConfig: VisParams;
}
type Return = Promise<Render<RenderValue>>;
export const createInputControlVisFn = (): ExpressionFunction<
typeof name,
Context,
Arguments,
Return
> => ({
name: 'input_control_vis',
type: 'render',
context: {
@ -33,9 +60,10 @@ export const inputControlVis = () => ({
visConfig: {
types: ['string'],
default: '"{}"',
help: '',
},
},
fn(context, args) {
async fn(context, args) {
const params = JSON.parse(args.visConfig);
return {
type: 'render',
@ -47,5 +75,3 @@ export const inputControlVis = () => ({
};
},
});
functionsRegistry.register(inputControlVis);

View file

@ -0,0 +1,75 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { i18n } from '@kbn/i18n';
import { createInputControlVisController } from './vis_controller';
import { getControlsTab } from './components/editor/controls_tab';
import { OptionsTab } from './components/editor/options_tab';
import { Status, defaultFeedbackMessage } from '../../visualizations/public';
import { InputControlVisDependencies } from './plugin';
export function createInputControlVisTypeDefinition(deps: InputControlVisDependencies) {
const InputControlVisController = createInputControlVisController(deps);
const ControlsTab = getControlsTab(deps);
return {
name: 'input_control_vis',
title: i18n.translate('inputControl.register.controlsTitle', {
defaultMessage: 'Controls',
}),
icon: 'visControls',
description: i18n.translate('inputControl.register.controlsDescription', {
defaultMessage: 'Create interactive controls for easy dashboard manipulation.',
}),
stage: 'experimental',
requiresUpdateStatus: [Status.PARAMS, Status.TIME],
feedbackMessage: defaultFeedbackMessage,
visualization: InputControlVisController,
visConfig: {
defaults: {
controls: [],
updateFiltersOnChange: false,
useTimeFilter: false,
pinFilters: false,
},
},
editor: 'default',
editorConfig: {
optionTabs: [
{
name: 'controls',
title: i18n.translate('inputControl.register.tabs.controlsTitle', {
defaultMessage: 'Controls',
}),
editor: ControlsTab,
},
{
name: 'options',
title: i18n.translate('inputControl.register.tabs.optionsTitle', {
defaultMessage: 'Options',
}),
editor: OptionsTab,
},
],
},
requestHandler: 'none',
responseHandler: 'none',
};
}

View file

@ -0,0 +1,49 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { PluginInitializerContext } from 'kibana/public';
import { npSetup, npStart } from 'ui/new_platform';
import { plugin } from '.';
import {
InputControlVisPluginSetupDependencies,
InputControlVisPluginStartDependencies,
} from './plugin';
import {
setup as visualizationsSetup,
start as visualizationsStart,
} from '../../visualizations/public/np_ready/public/legacy';
const setupPlugins: Readonly<InputControlVisPluginSetupDependencies> = {
expressions: npSetup.plugins.expressions,
data: npSetup.plugins.data,
visualizations: visualizationsSetup,
};
const startPlugins: Readonly<InputControlVisPluginStartDependencies> = {
expressions: npStart.plugins.expressions,
data: npStart.plugins.data,
visualizations: visualizationsStart,
};
const pluginInstance = plugin({} as PluginInitializerContext);
export const setup = pluginInstance.setup(npSetup.core, setupPlugins);
export const start = pluginInstance.start(npStart.core, startPlugins);

View file

@ -0,0 +1,29 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { SearchSource as SearchSourceClass } from 'ui/courier';
import { Class } from '@kbn/utility-types';
export { Vis, VisParams } from 'ui/vis';
export { VisOptionsProps } from 'ui/vis/editors/default';
export { ValidatedDualRange } from 'ui/validated_range';
export { SearchSourceFields } from 'ui/courier/types';
export type SearchSource = Class<SearchSourceClass>;
export const SearchSource = SearchSourceClass;

View file

@ -23,11 +23,11 @@ import { CONTROL_TYPES, newControl } from '../editor_utils';
test('creates lineage map', () => {
const control1 = newControl(CONTROL_TYPES.LIST);
control1.id = 1;
control1.id = '1';
const control2 = newControl(CONTROL_TYPES.LIST);
control2.id = 2;
control2.id = '2';
const control3 = newControl(CONTROL_TYPES.LIST);
control3.id = 3;
control3.id = '3';
control2.parent = control1.id;
control3.parent = control2.id;
@ -40,9 +40,9 @@ test('creates lineage map', () => {
test('safely handles circular graph', () => {
const control1 = newControl(CONTROL_TYPES.LIST);
control1.id = 1;
control1.id = '1';
const control2 = newControl(CONTROL_TYPES.LIST);
control2.id = 2;
control2.id = '2';
control1.parent = control2.id;
control2.parent = control1.id;

View file

@ -18,18 +18,19 @@
*/
import _ from 'lodash';
import { ControlParams } from '../editor_utils';
export function getLineageMap(controlParamsList) {
function getControlParamsById(controlId) {
export function getLineageMap(controlParamsList: ControlParams[]) {
function getControlParamsById(controlId: string) {
return controlParamsList.find(controlParams => {
return controlParams.id === controlId;
});
}
const lineageMap = new Map();
const lineageMap = new Map<string, string[]>();
controlParamsList.forEach(rootControlParams => {
const lineage = [rootControlParams.id];
const getLineage = controlParams => {
const getLineage = (controlParams: ControlParams) => {
if (
_.has(controlParams, 'parent') &&
controlParams.parent !== '' &&
@ -37,7 +38,10 @@ export function getLineageMap(controlParamsList) {
) {
lineage.push(controlParams.parent);
const parent = getControlParamsById(controlParams.parent);
getLineage(parent);
if (parent) {
getLineage(parent);
}
}
};

View file

@ -22,7 +22,7 @@ import { getLineageMap } from './lineage_map';
import { getParentCandidates } from './parent_candidates';
import { CONTROL_TYPES, newControl } from '../editor_utils';
function createControlParams(id) {
function createControlParams(id: any) {
const controlParams = newControl(CONTROL_TYPES.LIST);
controlParams.id = id;
controlParams.indexPattern = 'indexPatternId';

View file

@ -17,9 +17,13 @@
* under the License.
*/
import { getTitle } from '../editor_utils';
import { getTitle, ControlParams } from '../editor_utils';
export function getParentCandidates(controlParamsList, controlId, lineageMap) {
export function getParentCandidates(
controlParamsList: ControlParams[],
controlId: string,
lineageMap: Map<string, string[]>
) {
return controlParamsList
.filter(controlParams => {
// Ignore controls that do not have index pattern and field set
@ -28,7 +32,7 @@ export function getParentCandidates(controlParamsList, controlId, lineageMap) {
}
// Ignore controls that would create a circular graph
const lineage = lineageMap.get(controlParams.id);
if (lineage.includes(controlId)) {
if (lineage?.includes(controlId)) {
return false;
}
return true;

View file

@ -0,0 +1,70 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'kibana/public';
import { DataPublicPluginSetup, DataPublicPluginStart } from 'src/plugins/data/public';
import { Plugin as ExpressionsPublicPlugin } from '../../../../plugins/expressions/public';
import { VisualizationsSetup, VisualizationsStart } from '../../visualizations/public';
import { createInputControlVisFn } from './input_control_fn';
import { createInputControlVisTypeDefinition } from './input_control_vis_type';
type InputControlVisCoreSetup = CoreSetup<InputControlVisPluginStartDependencies>;
export interface InputControlVisDependencies {
core: InputControlVisCoreSetup;
data: DataPublicPluginSetup;
}
/** @internal */
export interface InputControlVisPluginSetupDependencies {
expressions: ReturnType<ExpressionsPublicPlugin['setup']>;
visualizations: VisualizationsSetup;
data: DataPublicPluginSetup;
}
/** @internal */
export interface InputControlVisPluginStartDependencies {
expressions: ReturnType<ExpressionsPublicPlugin['start']>;
visualizations: VisualizationsStart;
data: DataPublicPluginStart;
}
/** @internal */
export class InputControlVisPlugin implements Plugin<Promise<void>, void> {
constructor(public initializerContext: PluginInitializerContext) {}
public async setup(
core: InputControlVisCoreSetup,
{ expressions, visualizations, data }: InputControlVisPluginSetupDependencies
) {
const visualizationDependencies: Readonly<InputControlVisDependencies> = {
core,
data,
};
expressions.registerFunction(createInputControlVisFn);
visualizations.types.createBaseVisualization(
createInputControlVisTypeDefinition(visualizationDependencies)
);
}
public start(core: CoreStart, deps: InputControlVisPluginStartDependencies) {
// nothing to do here
}
}

View file

@ -1,72 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { VisController } from './vis_controller';
import { ControlsTab } from './components/editor/controls_tab';
import { OptionsTab } from './components/editor/options_tab';
import { i18n } from '@kbn/i18n';
import { setup as visualizations } from '../../visualizations/public/np_ready/public/legacy';
import { Status, defaultFeedbackMessage } from '../../visualizations/public';
export const inputControlVisDefinition = {
name: 'input_control_vis',
title: i18n.translate('inputControl.register.controlsTitle', {
defaultMessage: 'Controls',
}),
icon: 'visControls',
description: i18n.translate('inputControl.register.controlsDescription', {
defaultMessage: 'Create interactive controls for easy dashboard manipulation.',
}),
stage: 'experimental',
requiresUpdateStatus: [Status.PARAMS, Status.TIME],
feedbackMessage: defaultFeedbackMessage,
visualization: VisController,
visConfig: {
defaults: {
controls: [],
updateFiltersOnChange: false,
useTimeFilter: false,
pinFilters: false,
},
},
editor: 'default',
editorConfig: {
optionTabs: [
{
name: 'controls',
title: i18n.translate('inputControl.register.tabs.controlsTitle', {
defaultMessage: 'Controls',
}),
editor: ControlsTab,
},
{
name: 'options',
title: i18n.translate('inputControl.register.tabs.optionsTitle', {
defaultMessage: 'Options',
}),
editor: OptionsTab,
},
],
},
requestHandler: 'none',
responseHandler: 'none',
};
// register the provider with the visTypes registry
visualizations.types.createBaseVisualization(inputControlVisDefinition);

View file

@ -1,207 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { I18nContext } from 'ui/i18n';
import { InputControlVis } from './components/vis/input_control_vis';
import { controlFactory } from './control/control_factory';
import { getLineageMap } from './lineage';
import { npStart } from 'ui/new_platform';
import { SearchSource } from '../../../ui/public/courier/search_source/search_source';
class VisController {
constructor(el, vis) {
this.el = el;
this.vis = vis;
this.controls = [];
this.queryBarUpdateHandler = this.updateControlsFromKbn.bind(this);
this.filterManager = npStart.plugins.data.query.filterManager;
this.updateSubsciption = this.filterManager.getUpdates$().subscribe(this.queryBarUpdateHandler);
}
async render(visData, visParams, status) {
if (status.params || (visParams.useTimeFilter && status.time)) {
this.visParams = visParams;
this.controls = [];
this.controls = await this.initControls();
this.drawVis();
}
}
destroy() {
this.updateSubsciption.unsubscribe();
unmountComponentAtNode(this.el);
this.controls.forEach(control => control.destroy());
}
drawVis = () => {
render(
<I18nContext>
<InputControlVis
updateFiltersOnChange={this.visParams.updateFiltersOnChange}
controls={this.controls}
stageFilter={this.stageFilter}
submitFilters={this.submitFilters}
resetControls={this.updateControlsFromKbn}
clearControls={this.clearControls}
hasChanges={this.hasChanges}
hasValues={this.hasValues}
refreshControl={this.refreshControl}
/>
</I18nContext>,
this.el
);
};
async initControls() {
const controlParamsList = this.visParams.controls.filter(controlParams => {
// ignore controls that do not have indexPattern or field
return controlParams.indexPattern && controlParams.fieldName;
});
const controlFactoryPromises = controlParamsList.map(controlParams => {
const factory = controlFactory(controlParams);
return factory(controlParams, this.visParams.useTimeFilter, SearchSource);
});
const controls = await Promise.all(controlFactoryPromises);
const getControl = id => {
return controls.find(control => {
return id === control.id;
});
};
const controlInitPromises = [];
getLineageMap(controlParamsList).forEach((lineage, controlId) => {
// first lineage item is the control. remove it
lineage.shift();
const ancestors = [];
lineage.forEach(ancestorId => {
ancestors.push(getControl(ancestorId));
});
const control = getControl(controlId);
control.setAncestors(ancestors);
controlInitPromises.push(control.fetch());
});
await Promise.all(controlInitPromises);
return controls;
}
stageFilter = async (controlIndex, newValue) => {
this.controls[controlIndex].set(newValue);
if (this.visParams.updateFiltersOnChange) {
// submit filters on each control change
this.submitFilters();
} else {
// Do not submit filters, just update vis so controls are updated with latest value
await this.updateNestedControls();
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.filterManager.removeFilter(existingFilter);
});
});
// Clean up filter pills for nested controls that are now disabled because ancestors are not set.
// This has to be done after looking up the staged controls because otherwise removing a filter
// will re-sync the controls of all other filters.
this.controls.map(control => {
if (control.hasAncestors() && control.hasUnsetAncestor()) {
control.filterManager.findFilters().forEach(existingFilter => {
this.filterManager.removeFilter(existingFilter);
});
}
});
this.filterManager.addFilters(newFilters, this.visParams.pinFilters);
};
clearControls = async () => {
this.controls.forEach(control => {
control.clear();
});
await this.updateNestedControls();
this.drawVis();
};
updateControlsFromKbn = async () => {
this.controls.forEach(control => {
control.reset();
});
await this.updateNestedControls();
this.drawVis();
};
async updateNestedControls() {
const fetchPromises = this.controls.map(async control => {
if (control.hasAncestors()) {
await control.fetch();
}
});
return await Promise.all(fetchPromises);
}
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;
});
};
refreshControl = async (controlIndex, query) => {
await this.controls[controlIndex].fetch(query);
this.drawVis();
};
}
export { VisController };

View file

@ -0,0 +1,226 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { I18nStart } from 'kibana/public';
import { Vis, VisParams, SearchSource } from './legacy_imports';
import { InputControlVis } from './components/vis/input_control_vis';
import { getControlFactory } from './control/control_factory';
import { getLineageMap } from './lineage';
import { ControlParams } from './editor_utils';
import { RangeControl } from './control/range_control_factory';
import { ListControl } from './control/list_control_factory';
import { InputControlVisDependencies } from './plugin';
import { FilterManager, esFilters } from '../../../../plugins/data/public';
export const createInputControlVisController = (deps: InputControlVisDependencies) => {
return class InputControlVisController {
private I18nContext?: I18nStart['Context'];
controls: Array<RangeControl | ListControl>;
queryBarUpdateHandler: () => void;
filterManager: FilterManager;
updateSubsciption: any;
visParams?: VisParams;
constructor(public el: Element, public vis: Vis) {
this.controls = [];
this.queryBarUpdateHandler = this.updateControlsFromKbn.bind(this);
this.filterManager = deps.data.query.filterManager;
this.updateSubsciption = this.filterManager
.getUpdates$()
.subscribe(this.queryBarUpdateHandler);
}
async render(visData: any, visParams: VisParams, status: any) {
if (status.params || (visParams.useTimeFilter && status.time)) {
this.visParams = visParams;
this.controls = [];
this.controls = await this.initControls();
const [{ i18n }] = await deps.core.getStartServices();
this.I18nContext = i18n.Context;
this.drawVis();
}
}
destroy() {
this.updateSubsciption.unsubscribe();
unmountComponentAtNode(this.el);
this.controls.forEach(control => control.destroy());
}
drawVis = () => {
if (!this.I18nContext) {
throw new Error('no i18n context found');
}
render(
<this.I18nContext>
<InputControlVis
updateFiltersOnChange={this.visParams?.updateFiltersOnChange}
controls={this.controls}
stageFilter={this.stageFilter}
submitFilters={this.submitFilters}
resetControls={this.updateControlsFromKbn}
clearControls={this.clearControls}
hasChanges={this.hasChanges}
hasValues={this.hasValues}
refreshControl={this.refreshControl}
/>
</this.I18nContext>,
this.el
);
};
async initControls() {
const controlParamsList = (this.visParams?.controls as ControlParams[])?.filter(
controlParams => {
// ignore controls that do not have indexPattern or field
return controlParams.indexPattern && controlParams.fieldName;
}
);
const controlFactoryPromises = controlParamsList.map(controlParams => {
const factory = getControlFactory(controlParams);
return factory(controlParams, this.visParams?.useTimeFilter, SearchSource, deps);
});
const controls = await Promise.all<RangeControl | ListControl>(controlFactoryPromises);
const getControl = (controlId: string) => {
return controls.find(({ id }) => id === controlId);
};
const controlInitPromises: Array<Promise<void>> = [];
getLineageMap(controlParamsList).forEach((lineage, controlId) => {
// first lineage item is the control. remove it
lineage.shift();
const ancestors: Array<RangeControl | ListControl> = [];
lineage.forEach(ancestorId => {
const control = getControl(ancestorId);
if (control) {
ancestors.push(control);
}
});
const control = getControl(controlId);
if (control) {
control.setAncestors(ancestors);
controlInitPromises.push(control.fetch());
}
});
await Promise.all(controlInitPromises);
return controls;
}
stageFilter = async (controlIndex: number, newValue: any) => {
this.controls[controlIndex].set(newValue);
if (this.visParams?.updateFiltersOnChange) {
// submit filters on each control change
this.submitFilters();
} else {
// Do not submit filters, just update vis so controls are updated with latest value
await this.updateNestedControls();
this.drawVis();
}
};
submitFilters = () => {
const stagedControls = this.controls.filter(control => {
return control.hasChanged();
});
const newFilters = stagedControls
.map(control => control.getKbnFilter())
.filter((filter): filter is esFilters.Filter => {
return filter !== null;
});
stagedControls.forEach(control => {
// to avoid duplicate filters, remove any old filters for control
control.filterManager.findFilters().forEach(existingFilter => {
this.filterManager.removeFilter(existingFilter);
});
});
// Clean up filter pills for nested controls that are now disabled because ancestors are not set.
// This has to be done after looking up the staged controls because otherwise removing a filter
// will re-sync the controls of all other filters.
this.controls.map(control => {
if (control.hasAncestors() && control.hasUnsetAncestor()) {
control.filterManager.findFilters().forEach(existingFilter => {
this.filterManager.removeFilter(existingFilter);
});
}
});
this.filterManager.addFilters(newFilters, this.visParams?.pinFilters);
};
clearControls = async () => {
this.controls.forEach(control => {
control.clear();
});
await this.updateNestedControls();
this.drawVis();
};
updateControlsFromKbn = async () => {
this.controls.forEach(control => {
control.reset();
});
await this.updateNestedControls();
this.drawVis();
};
async updateNestedControls() {
const fetchPromises = this.controls.map(async control => {
if (control.hasAncestors()) {
await control.fetch();
}
});
return await Promise.all(fetchPromises);
}
hasChanges = () => {
return this.controls.map(control => control.hasChanged()).some(control => control);
};
hasValues = () => {
return this.controls
.map(control => {
return control.hasValue();
})
.reduce((a, b) => {
return a || b;
});
};
refreshControl = async (controlIndex: number, query: any) => {
await this.controls[controlIndex].fetch(query);
this.drawVis();
};
};
};

View file

@ -19,9 +19,9 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, I18nProvider } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { EuiLink, EuiButton, EuiEmptyPrompt } from '@elastic/eui';
import { npStart } from 'ui/new_platform';

View file

@ -17,8 +17,7 @@
* under the License.
*/
import { NameList } from 'elasticsearch';
import { esFilters, Query } from '../../../../../plugins/data/public';
import { IndexPattern } from '../../../../core_plugins/data/public/index_patterns';
import { esFilters, Query, IndexPattern } from '../../../../../plugins/data/public';
export type EsQuerySearchAfter = [string | number, string | number];
@ -47,6 +46,8 @@ export interface SearchSourceFields {
fields?: NameList;
index?: IndexPattern;
searchAfter?: EsQuerySearchAfter;
timeout?: string;
terminate_after?: number;
}
export interface SearchSourceOptions {

View file

@ -42,7 +42,11 @@ export const getPhrasesFilterField = (filter: PhrasesFilter) => {
// Creates a filter where the given field matches one or more of the given values
// params should be an array of values
export const buildPhrasesFilter = (field: IFieldType, params: any, indexPattern: IIndexPattern) => {
export const buildPhrasesFilter = (
field: IFieldType,
params: any[],
indexPattern: IIndexPattern
) => {
const index = indexPattern.id;
const type = FILTERS.PHRASES;
const key = field.name;

View file

@ -18,8 +18,9 @@
*/
import { get } from 'lodash';
import { IIndexPattern } from '../..';
export function getFromSavedObject(savedObject: any) {
export function getFromSavedObject(savedObject: any): IIndexPattern | undefined {
if (get(savedObject, 'attributes.fields') === undefined) {
return;
}

View file

@ -18,10 +18,10 @@
*/
import dateMath from '@elastic/datemath';
import { TimeRange } from '../../../common';
import { IIndexPattern } from '../..';
import { TimeRange, IFieldType } from '../../../common';
// TODO: remove this
import { IndexPattern, Field } from '../../../../../legacy/core_plugins/data/public';
import { esFilters } from '../../../common';
interface CalculateBoundsOptions {
@ -36,7 +36,7 @@ export function calculateBounds(timeRange: TimeRange, options: CalculateBoundsOp
}
export function getTime(
indexPattern: IndexPattern | undefined,
indexPattern: IIndexPattern | undefined,
timeRange: TimeRange,
forceNow?: Date
) {
@ -45,7 +45,7 @@ export function getTime(
return;
}
const timefield: Field | undefined = indexPattern.fields.find(
const timefield: IFieldType | undefined = indexPattern.fields.find(
field => field.name === indexPattern.timeFieldName
);

View file

@ -20,18 +20,21 @@
import _ from 'lodash';
import React, { Component } from 'react';
import { EuiComboBox } from '@elastic/eui';
import { Required } from '@kbn/utility-types';
import { EuiComboBox, EuiComboBoxProps } from '@elastic/eui';
import { SavedObjectsClientContract, SimpleSavedObject } from '../../../../../core/public';
import { getTitle } from '../../index_patterns/lib';
export interface IndexPatternSelectProps {
onChange: (opt: any) => void;
export type IndexPatternSelectProps = Required<
Omit<EuiComboBoxProps<any>, 'isLoading' | 'onSearchChange' | 'options' | 'selectedOptions'>,
'onChange' | 'placeholder'
> & {
indexPatternId: string;
placeholder: string;
fieldTypes: string[];
onNoIndexPatterns: () => void;
fieldTypes?: string[];
onNoIndexPatterns?: () => void;
savedObjectsClient: SavedObjectsClientContract;
}
};
interface IndexPatternSelectState {
isLoading: boolean;
@ -136,7 +139,7 @@ export class IndexPatternSelect extends Component<IndexPatternSelectProps> {
try {
const indexPatternFields = JSON.parse(savedObject.attributes.fields as any);
return indexPatternFields.some((field: any) => {
return fieldTypes.includes(field.type);
return fieldTypes?.includes(field.type);
});
} catch (err) {
// Unable to parse fields JSON, invalid index pattern
@ -196,6 +199,7 @@ export class IndexPatternSelect extends Component<IndexPatternSelectProps> {
return (
<EuiComboBox
{...rest}
placeholder={placeholder}
singleSelection={true}
isLoading={this.state.isLoading}
@ -203,7 +207,6 @@ export class IndexPatternSelect extends Component<IndexPatternSelectProps> {
options={this.state.options}
selectedOptions={this.state.selectedIndexPattern ? [this.state.selectedIndexPattern] : []}
onChange={this.onChange}
{...rest}
/>
);
}

View file

@ -92,7 +92,6 @@ describe('getLinearGradient', () => {
'rgb(32,112,180)',
'rgb(7,47,107)',
];
expect(getLinearGradient(colorRamp)).toBe(
'linear-gradient(to right, rgb(247,250,255) 0%, rgb(221,234,247) 14%, rgb(197,218,238) 28%, rgb(157,201,224) 42%, rgb(106,173,213) 57%, rgb(65,145,197) 71%, rgb(32,112,180) 85%, rgb(7,47,107) 100%)'
);

View file

@ -27,7 +27,6 @@ describe('styleSvg', () => {
const unstyledSvgString =
'<svg version="1.1" width="11px" height="11px" viewBox="0 0 11 11"><path/></svg>';
const styledSvg = await styleSvg(unstyledSvgString, 'red');
expect(styledSvg.split('\n')[1]).toBe(
'<svg version="1.1" width="11px" height="11px" viewBox="0 0 11 11" style="fill:red;">'
);
@ -37,7 +36,6 @@ describe('styleSvg', () => {
const unstyledSvgString =
'<svg version="1.1" width="11px" height="11px" viewBox="0 0 11 11"><path/></svg>';
const styledSvg = await styleSvg(unstyledSvgString, 'red', 'white');
expect(styledSvg.split('\n')[1]).toBe(
'<svg version="1.1" width="11px" height="11px" viewBox="0 0 11 11" style="fill:red;stroke:white;">'
);
@ -47,7 +45,6 @@ describe('styleSvg', () => {
const unstyledSvgString =
'<svg version="1.1" width="11px" height="11px" viewBox="0 0 11 11"><path/></svg>';
const styledSvg = await styleSvg(unstyledSvgString, 'red', 'white', '2px');
expect(styledSvg.split('\n')[1]).toBe(
'<svg version="1.1" width="11px" height="11px" viewBox="0 0 11 11" style="fill:red;stroke:white;stroke-width:2px;">'
);

View file

@ -81,7 +81,6 @@ export class VectorStyle extends AbstractStyle {
this._descriptor.properties[VECTOR_STYLES.ICON_SIZE],
VECTOR_STYLES.ICON_SIZE
);
this._iconOrientationProperty = this._makeOrientationProperty(
this._descriptor.properties[VECTOR_STYLES.ICON_ORIENTATION],
VECTOR_STYLES.ICON_ORIENTATION

View file

@ -42,7 +42,6 @@ export function getEMSClient() {
false
);
const proxyPath = proxyElasticMapsServiceInMaps ? relativeToAbsolute('..') : '';
const manifestServiceUrl = proxyElasticMapsServiceInMaps
? relativeToAbsolute(`${GIS_API_RELATIVE}/${EMS_CATALOGUE_PATH}`)
: chrome.getInjected('emsManifestServiceUrl');

View file

@ -29217,10 +29217,10 @@ utila@^0.4.0, utila@~0.4:
resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c"
integrity sha1-ihagXURWV6Oupe7MWxKk+lN5dyw=
utility-types@^3.7.0:
version "3.7.0"
resolved "https://registry.yarnpkg.com/utility-types/-/utility-types-3.7.0.tgz#51f1c29fa35d4267488345706efcf3f68f2b1933"
integrity sha512-mqRJXN7dEArK/NZNJUubjr9kbFFVZcmF/JHDc9jt5O/aYXUVmopHYujDMhLmLil1Bxo2+khe6KAIVvDH9Yc4VA==
utility-types@^3.10.0:
version "3.10.0"
resolved "https://registry.yarnpkg.com/utility-types/-/utility-types-3.10.0.tgz#ea4148f9a741015f05ed74fd615e1d20e6bed82b"
integrity sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg==
utils-copy-error@^1.0.0:
version "1.0.1"