diff --git a/src/plugins/controls/common/control_types/range_slider/range_slider_persistable_state.ts b/src/plugins/controls/common/control_types/range_slider/range_slider_persistable_state.ts new file mode 100644 index 0000000000000..c50fe3f6fb710 --- /dev/null +++ b/src/plugins/controls/common/control_types/range_slider/range_slider_persistable_state.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + EmbeddableStateWithType, + EmbeddablePersistableStateService, +} from '../../../../embeddable/common'; +import { RangeSliderEmbeddableInput } from './types'; +import { SavedObjectReference } from '../../../../../core/types'; +import { DATA_VIEW_SAVED_OBJECT_TYPE } from '../../../../data_views/common'; + +type RangeSliderInputWithType = Partial & { type: string }; +const dataViewReferenceName = 'optionsListDataView'; + +export const createRangeSliderInject = (): EmbeddablePersistableStateService['inject'] => { + return (state: EmbeddableStateWithType, references: SavedObjectReference[]) => { + const workingState = { ...state } as EmbeddableStateWithType | RangeSliderInputWithType; + references.forEach((reference) => { + if (reference.name === dataViewReferenceName) { + (workingState as RangeSliderInputWithType).dataViewId = reference.id; + } + }); + return workingState as EmbeddableStateWithType; + }; +}; + +export const createRangeSliderExtract = (): EmbeddablePersistableStateService['extract'] => { + return (state: EmbeddableStateWithType) => { + const workingState = { ...state } as EmbeddableStateWithType | RangeSliderInputWithType; + const references: SavedObjectReference[] = []; + + if ('dataViewId' in workingState) { + references.push({ + name: dataViewReferenceName, + type: DATA_VIEW_SAVED_OBJECT_TYPE, + id: workingState.dataViewId!, + }); + delete workingState.dataViewId; + } + return { state: workingState as EmbeddableStateWithType, references }; + }; +}; diff --git a/src/plugins/controls/common/control_types/range_slider/types.ts b/src/plugins/controls/common/control_types/range_slider/types.ts new file mode 100644 index 0000000000000..e63ec0337a57e --- /dev/null +++ b/src/plugins/controls/common/control_types/range_slider/types.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ControlInput } from '../../types'; + +export const RANGE_SLIDER_CONTROL = 'rangeSliderControl'; + +export type RangeValue = [string, string]; + +export interface RangeSliderEmbeddableInput extends ControlInput { + fieldName: string; + dataViewId: string; + value: RangeValue; +} diff --git a/src/plugins/controls/common/index.ts b/src/plugins/controls/common/index.ts index e10258026e64d..23779e225ce47 100644 --- a/src/plugins/controls/common/index.ts +++ b/src/plugins/controls/common/index.ts @@ -6,10 +6,13 @@ * Side Public License, v 1. */ +export type { ControlWidth } from './types'; export type { ControlPanelState, ControlsPanels, ControlGroupInput } from './control_group/types'; export type { OptionsListEmbeddableInput } from './control_types/options_list/types'; -export type { ControlWidth } from './types'; +export type { RangeSliderEmbeddableInput } from './control_types/range_slider/types'; -export { OPTIONS_LIST_CONTROL } from './control_types/options_list/types'; export { CONTROL_GROUP_TYPE } from './control_group/types'; +export { OPTIONS_LIST_CONTROL } from './control_types/options_list/types'; +export { RANGE_SLIDER_CONTROL } from './control_types/range_slider/types'; + export { getDefaultControlGroupInput } from './control_group/control_group_constants'; diff --git a/src/plugins/controls/public/__stories__/controls.stories.tsx b/src/plugins/controls/public/__stories__/controls.stories.tsx index 436cee506916a..12bf0cacbe136 100644 --- a/src/plugins/controls/public/__stories__/controls.stories.tsx +++ b/src/plugins/controls/public/__stories__/controls.stories.tsx @@ -19,7 +19,9 @@ import { import { ControlGroupContainerFactory, OptionsListEmbeddableInput, + RangeSliderEmbeddableInput, OPTIONS_LIST_CONTROL, + RANGE_SLIDER_CONTROL, } from '../'; import { ViewMode } from '../../../embeddable/public'; @@ -169,6 +171,65 @@ export const ConfiguredControlGroupStory = () => ( fieldName: 'Carrier', } as OptionsListEmbeddableInput, }, + rangeSlider1: { + type: RANGE_SLIDER_CONTROL, + order: 4, + width: 'auto', + explicitInput: { + id: 'rangeSlider1', + title: 'Average ticket price', + dataViewId: 'demoDataFlights', + fieldName: 'AvgTicketPrice', + value: ['4', '12'], + step: 2, + } as RangeSliderEmbeddableInput, + }, + }} + /> +); + +export const RangeSliderControlGroupStory = () => ( + ); diff --git a/src/plugins/controls/public/__stories__/storybook_control_factories.ts b/src/plugins/controls/public/__stories__/storybook_control_factories.ts index 9809e90bd12fc..12674a97d856d 100644 --- a/src/plugins/controls/public/__stories__/storybook_control_factories.ts +++ b/src/plugins/controls/public/__stories__/storybook_control_factories.ts @@ -7,6 +7,7 @@ */ import { OptionsListEmbeddableFactory } from '../control_types/options_list'; +import { RangeSliderEmbeddableFactory } from '../control_types/range_slider'; import { ControlsService } from '../services/controls'; import { ControlFactory } from '..'; @@ -17,4 +18,11 @@ export const populateStorybookControlFactories = (controlsServiceStub: ControlsS const optionsListControlFactory = optionsListFactoryStub as unknown as ControlFactory; optionsListControlFactory.getDefaultInput = () => ({}); controlsServiceStub.registerControlType(optionsListControlFactory); + + const rangeSliderFactoryStub = new RangeSliderEmbeddableFactory(); + + // cast to unknown because the stub cannot use the embeddable start contract to transform the EmbeddableFactoryDefinition into an EmbeddableFactory + const rangeSliderControlFactory = rangeSliderFactoryStub as unknown as ControlFactory; + rangeSliderControlFactory.getDefaultInput = () => ({}); + controlsServiceStub.registerControlType(rangeSliderControlFactory); }; diff --git a/src/plugins/controls/public/control_types/index.ts b/src/plugins/controls/public/control_types/index.ts index 141e9f9b4d55f..2b53723f4bafd 100644 --- a/src/plugins/controls/public/control_types/index.ts +++ b/src/plugins/controls/public/control_types/index.ts @@ -7,3 +7,4 @@ */ export * from './options_list'; +export * from './range_slider'; diff --git a/src/plugins/controls/public/control_types/options_list/options_list_strings.ts b/src/plugins/controls/public/control_types/options_list/options_list_strings.ts index 62fb54163c2bd..f6dd7f3a2ddf1 100644 --- a/src/plugins/controls/public/control_types/options_list/options_list_strings.ts +++ b/src/plugins/controls/public/control_types/options_list/options_list_strings.ts @@ -28,10 +28,6 @@ export const OptionsListStrings = { }), }, editor: { - getIndexPatternTitle: () => - i18n.translate('controls.optionsList.editor.indexPatternTitle', { - defaultMessage: 'Index pattern', - }), getDataViewTitle: () => i18n.translate('controls.optionsList.editor.dataViewTitle', { defaultMessage: 'Data view', diff --git a/src/plugins/controls/public/control_types/range_slider/index.ts b/src/plugins/controls/public/control_types/range_slider/index.ts new file mode 100644 index 0000000000000..5fdd29672b86f --- /dev/null +++ b/src/plugins/controls/public/control_types/range_slider/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { RANGE_SLIDER_CONTROL } from '../../../common/control_types/range_slider/types'; +export { RangeSliderEmbeddableFactory } from './range_slider_embeddable_factory'; + +export type { RangeSliderEmbeddable } from './range_slider_embeddable'; +export type { RangeSliderEmbeddableInput } from '../../../common/control_types/range_slider/types'; diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider.component.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider.component.tsx new file mode 100644 index 0000000000000..822c88ca10df0 --- /dev/null +++ b/src/plugins/controls/public/control_types/range_slider/range_slider.component.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC, useCallback, useState } from 'react'; +import { BehaviorSubject } from 'rxjs'; + +import { DataViewField } from '../../../../data_views/public'; +import { useReduxEmbeddableContext } from '../../../../presentation_util/public'; +import { useStateObservable } from '../../hooks/use_state_observable'; +import { RangeSliderPopover } from './range_slider_popover'; +import { rangeSliderReducers } from './range_slider_reducers'; +import { RangeSliderEmbeddableInput, RangeValue } from './types'; + +import './range_slider.scss'; + +interface Props { + componentStateSubject: BehaviorSubject; +} +// Availableoptions and loading state is controled by the embeddable, but is not considered embeddable input. +export interface RangeSliderComponentState { + field?: DataViewField; + fieldFormatter: (value: string) => string; + min: string; + max: string; + loading: boolean; +} + +export const RangeSliderComponent: FC = ({ componentStateSubject }) => { + // Redux embeddable Context to get state from Embeddable input + const { + useEmbeddableDispatch, + useEmbeddableSelector, + actions: { selectRange }, + } = useReduxEmbeddableContext(); + const dispatch = useEmbeddableDispatch(); + + // useStateObservable to get component state from Embeddable + const { loading, min, max, fieldFormatter } = useStateObservable( + componentStateSubject, + componentStateSubject.getValue() + ); + + const { value = ['', ''], id, title } = useEmbeddableSelector((state) => state); + + const [selectedValue, setSelectedValue] = useState(value || ['', '']); + + const onChangeComplete = useCallback( + (range: RangeValue) => { + dispatch(selectRange(range)); + setSelectedValue(range); + }, + [selectRange, setSelectedValue, dispatch] + ); + + return ( + + ); +}; diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider.scss b/src/plugins/controls/public/control_types/range_slider/range_slider.scss new file mode 100644 index 0000000000000..82d892cd0b9c5 --- /dev/null +++ b/src/plugins/controls/public/control_types/range_slider/range_slider.scss @@ -0,0 +1,57 @@ +.rangeSlider__popoverOverride { + height: 100%; + max-width: 100%; + width: 100%; +} + +@include euiBreakpoint('m', 'l', 'xl') { + .rangeSlider__panelOverride { + min-width: $euiSizeXXL * 12; + } +} + +.rangeSlider__anchorOverride { + >div { + height: 100%; + } +} + +.rangeSliderAnchor__button { + display: flex; + align-items: center; + width: 100%; + height: 100%; + justify-content: space-between; + background-color: $euiFormBackgroundColor; + @include euiFormControlSideBorderRadius($euiFormControlBorderRadius, $side: 'right', $internal: true); + + .euiToolTipAnchor { + width: 100%; + } + + .rangeSliderAnchor__delimiter { + background-color: unset; + } + .rangeSliderAnchor__fieldNumber { + font-weight: $euiFontWeightBold; + box-shadow: none; + text-align: center; + background-color: unset; + + &::placeholder { + font-weight: $euiFontWeightRegular; + color: $euiColorMediumShade; + text-decoration: none; + } + } + + .rangeSliderAnchor__fieldNumber--invalid { + text-decoration: line-through; + font-weight: $euiFontWeightRegular; + color: $euiColorMediumShade; + } + + .rangeSliderAnchor__spinner { + padding-right: $euiSizeS; + } +} \ No newline at end of file diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx new file mode 100644 index 0000000000000..d2dbef62f2b0f --- /dev/null +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import useMount from 'react-use/lib/useMount'; +import React, { useEffect, useState } from 'react'; +import { EuiFormRow } from '@elastic/eui'; + +import { pluginServices } from '../../services'; +import { ControlEditorProps } from '../../types'; +import { RangeSliderEmbeddableInput } from './types'; +import { RangeSliderStrings } from './range_slider_strings'; +import { DataViewListItem, DataView } from '../../../../data_views/common'; +import { + LazyDataViewPicker, + LazyFieldPicker, + withSuspense, +} from '../../../../presentation_util/public'; + +interface RangeSliderEditorState { + dataViewListItems: DataViewListItem[]; + dataView?: DataView; + fieldName?: string; +} + +const FieldPicker = withSuspense(LazyFieldPicker, null); +const DataViewPicker = withSuspense(LazyDataViewPicker, null); + +export const RangeSliderEditor = ({ + onChange, + initialInput, + setValidState, + setDefaultTitle, +}: ControlEditorProps) => { + // Controls Services Context + const { dataViews } = pluginServices.getHooks(); + const { getIdsWithTitle, getDefaultId, get } = dataViews.useService(); + + const [state, setState] = useState({ + fieldName: initialInput?.fieldName, + dataViewListItems: [], + }); + + useMount(() => { + let mounted = true; + if (state.fieldName) setDefaultTitle(state.fieldName); + (async () => { + const dataViewListItems = await getIdsWithTitle(); + const initialId = initialInput?.dataViewId ?? (await getDefaultId()); + let dataView: DataView | undefined; + if (initialId) { + onChange({ dataViewId: initialId }); + dataView = await get(initialId); + } + if (!mounted) return; + setState((s) => ({ ...s, dataView, dataViewListItems })); + })(); + return () => { + mounted = false; + }; + }); + + useEffect( + () => setValidState(Boolean(state.fieldName) && Boolean(state.dataView)), + [state.fieldName, setValidState, state.dataView] + ); + + const { dataView, fieldName } = state; + return ( + <> + + { + onChange({ dataViewId }); + get(dataViewId).then((newDataView) => + setState((s) => ({ ...s, dataView: newDataView })) + ); + }} + trigger={{ + label: state.dataView?.title ?? RangeSliderStrings.editor.getNoDataViewTitle(), + }} + /> + + + field.aggregatable && field.type === 'number'} + selectedFieldName={fieldName} + dataView={dataView} + onSelectField={(field) => { + setDefaultTitle(field.displayName ?? field.name); + onChange({ fieldName: field.name }); + setState((s) => ({ ...s, fieldName: field.name })); + }} + /> + + + ); +}; diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable.tsx new file mode 100644 index 0000000000000..ef4bc41abeefc --- /dev/null +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable.tsx @@ -0,0 +1,330 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { isEmpty } from 'lodash'; +import { + compareFilters, + buildRangeFilter, + COMPARE_ALL_OPTIONS, + RangeFilterParams, +} from '@kbn/es-query'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { get, isEqual } from 'lodash'; +import deepEqual from 'fast-deep-equal'; +import { Subscription, BehaviorSubject } from 'rxjs'; +import { debounceTime, distinctUntilChanged, skip, map } from 'rxjs/operators'; + +import { + withSuspense, + LazyReduxEmbeddableWrapper, + ReduxEmbeddableWrapperPropsWithChildren, +} from '../../../../presentation_util/public'; +import { Embeddable, IContainer } from '../../../../embeddable/public'; +import { DataView, DataViewField } from '../../../../data_views/public'; + +import { ControlsDataViewsService } from '../../services/data_views'; +import { ControlsDataService } from '../../services/data'; +import { ControlInput, ControlOutput } from '../..'; +import { pluginServices } from '../../services'; + +import { RangeSliderComponent, RangeSliderComponentState } from './range_slider.component'; +import { rangeSliderReducers } from './range_slider_reducers'; +import { RangeSliderStrings } from './range_slider_strings'; +import { RangeSliderEmbeddableInput, RANGE_SLIDER_CONTROL } from './types'; + +const RangeSliderReduxWrapper = withSuspense< + ReduxEmbeddableWrapperPropsWithChildren +>(LazyReduxEmbeddableWrapper); + +const diffDataFetchProps = ( + current?: RangeSliderDataFetchProps, + last?: RangeSliderDataFetchProps +) => { + if (!current || !last) return false; + const { filters: currentFilters, ...currentWithoutFilters } = current; + const { filters: lastFilters, ...lastWithoutFilters } = last; + if (!deepEqual(currentWithoutFilters, lastWithoutFilters)) return false; + if (!compareFilters(lastFilters ?? [], currentFilters ?? [], COMPARE_ALL_OPTIONS)) return false; + return true; +}; + +interface RangeSliderDataFetchProps { + fieldName: string; + dataViewId: string; + query?: ControlInput['query']; + filters?: ControlInput['filters']; +} + +const fieldMissingError = (fieldName: string) => + new Error(`field ${fieldName} not found in index pattern`); + +export class RangeSliderEmbeddable extends Embeddable { + public readonly type = RANGE_SLIDER_CONTROL; + public deferEmbeddableLoad = true; + + private subscriptions: Subscription = new Subscription(); + private node?: HTMLElement; + + // Controls services + private dataService: ControlsDataService; + private dataViewsService: ControlsDataViewsService; + + // Internal data fetching state for this input control. + private dataView?: DataView; + private field?: DataViewField; + + // State to be passed down to component + private componentState: RangeSliderComponentState; + private componentStateSubject$ = new BehaviorSubject({ + min: '', + max: '', + loading: true, + fieldFormatter: (value: string) => value, + }); + + constructor(input: RangeSliderEmbeddableInput, output: ControlOutput, parent?: IContainer) { + super(input, output, parent); // get filters for initial output... + + // Destructure controls services + ({ data: this.dataService, dataViews: this.dataViewsService } = pluginServices.getServices()); + + this.componentState = { + min: '', + max: '', + loading: true, + fieldFormatter: (value: string) => value, + }; + this.updateComponentState(this.componentState); + + this.initialize(); + } + + private initialize = async () => { + const initialValue = this.getInput().value; + if (!initialValue) { + this.setInitializationFinished(); + } + + this.fetchMinMax().then(async () => { + if (initialValue) { + this.setInitializationFinished(); + } + this.setupSubscriptions(); + }); + }; + + private setupSubscriptions = () => { + const dataFetchPipe = this.getInput$().pipe( + map((newInput) => ({ + lastReloadRequestTime: newInput.lastReloadRequestTime, + dataViewId: newInput.dataViewId, + fieldName: newInput.fieldName, + timeRange: newInput.timeRange, + filters: newInput.filters, + query: newInput.query, + })), + distinctUntilChanged(diffDataFetchProps), + skip(1) + ); + + // fetch available min/max when input changes + this.subscriptions.add(dataFetchPipe.subscribe(this.fetchMinMax)); + + // build filters when value change + this.subscriptions.add( + this.getInput$() + .pipe( + debounceTime(400), + distinctUntilChanged((a, b) => isEqual(a.value, b.value)), + skip(1) // skip the first input update because initial filters will be built by initialize. + ) + .subscribe(this.buildFilter) + ); + }; + + private getCurrentDataViewAndField = async (): Promise<{ + dataView: DataView; + field: DataViewField; + }> => { + const { dataViewId, fieldName } = this.getInput(); + if (!this.dataView || this.dataView.id !== dataViewId) { + this.dataView = await this.dataViewsService.get(dataViewId); + if (this.dataView === undefined) { + this.onFatalError( + new Error(RangeSliderStrings.errors.getDataViewNotFoundError(dataViewId)) + ); + } + } + + if (!this.field || this.field.name !== fieldName) { + this.field = this.dataView.getFieldByName(fieldName); + if (this.field === undefined) { + this.onFatalError(new Error(RangeSliderStrings.errors.getDataViewNotFoundError(fieldName))); + } + + this.updateComponentState({ + field: this.field, + fieldFormatter: this.field + ? this.dataView.getFormatterForField(this.field).getConverterFor('text') + : (value: string) => value, + }); + } + + return { dataView: this.dataView, field: this.field! }; + }; + + private updateComponentState(changes: Partial) { + this.componentState = { + ...this.componentState, + ...changes, + }; + this.componentStateSubject$.next(this.componentState); + } + + private minMaxAgg = (field?: DataViewField) => { + 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, + }, + minAgg: { + min: aggBody, + }, + }; + }; + + private fetchMinMax = async () => { + this.updateComponentState({ loading: true }); + this.updateOutput({ loading: true }); + const { dataView, field } = await this.getCurrentDataViewAndField(); + const embeddableInput = this.getInput(); + const { ignoreParentSettings, fieldName, query, timeRange } = embeddableInput; + let { filters = [] } = embeddableInput; + + if (!field) { + this.updateComponentState({ loading: false }); + this.updateOutput({ loading: false }); + throw fieldMissingError(fieldName); + } + + if (ignoreParentSettings?.ignoreFilters) { + filters = []; + } + + if (!ignoreParentSettings?.ignoreTimerange && timeRange) { + const timeFilter = this.dataService.timefilter.createFilter(dataView, timeRange); + if (timeFilter) { + filters = filters.concat(timeFilter); + } + } + + const searchSource = await this.dataService.searchSource.create(); + searchSource.setField('size', 0); + searchSource.setField('index', dataView); + + const aggs = this.minMaxAgg(field); + searchSource.setField('aggs', aggs); + + searchSource.setField('filter', filters); + + if (!ignoreParentSettings?.ignoreQuery) { + searchSource.setField('query', query); + } + + const resp = await searchSource.fetch$().toPromise(); + + const min = get(resp, 'rawResponse.aggregations.minAgg.value', ''); + const max = get(resp, 'rawResponse.aggregations.maxAgg.value', ''); + + this.updateComponentState({ + min: `${min ?? ''}`, + max: `${max ?? ''}`, + }); + + // build filter with new min/max + await this.buildFilter(); + }; + + private buildFilter = async () => { + const { value: [selectedMin, selectedMax] = ['', ''], ignoreParentSettings } = this.getInput(); + + const hasData = !isEmpty(this.componentState.min) && !isEmpty(this.componentState.max); + const hasLowerSelection = !isEmpty(selectedMin); + const hasUpperSelection = !isEmpty(selectedMax); + const hasEitherSelection = hasLowerSelection || hasUpperSelection; + const hasBothSelections = hasLowerSelection && hasUpperSelection; + const hasInvalidSelection = + !ignoreParentSettings?.ignoreValidations && + hasBothSelections && + parseFloat(selectedMin) > parseFloat(selectedMax); + const isLowerSelectionOutOfRange = + hasLowerSelection && parseFloat(selectedMin) > parseFloat(this.componentState.max); + const isUpperSelectionOutOfRange = + hasUpperSelection && parseFloat(selectedMax) < parseFloat(this.componentState.min); + const isSelectionOutOfRange = + (!ignoreParentSettings?.ignoreValidations && hasData && isLowerSelectionOutOfRange) || + isUpperSelectionOutOfRange; + const { dataView, field } = await this.getCurrentDataViewAndField(); + + if (!hasData || !hasEitherSelection || hasInvalidSelection || isSelectionOutOfRange) { + this.updateComponentState({ loading: false }); + this.updateOutput({ filters: [], dataViews: [dataView], loading: false }); + return; + } + + const params = {} as RangeFilterParams; + + if (selectedMin) { + params.gte = selectedMin; + } + if (selectedMax) { + params.lte = selectedMax; + } + + const rangeFilter = buildRangeFilter(field, params, dataView); + + rangeFilter.meta.key = field?.name; + + this.updateComponentState({ loading: false }); + this.updateOutput({ filters: [rangeFilter], dataViews: [dataView], loading: false }); + }; + + reload = () => { + this.fetchMinMax(); + }; + + public destroy = () => { + super.destroy(); + this.subscriptions.unsubscribe(); + }; + + public render = (node: HTMLElement) => { + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + this.node = node; + ReactDOM.render( + + + , + node + ); + }; +} diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable_factory.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable_factory.tsx new file mode 100644 index 0000000000000..fbdaff4f5b349 --- /dev/null +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable_factory.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import deepEqual from 'fast-deep-equal'; + +import { RangeSliderEditor } from './range_slider_editor'; +import { ControlEmbeddable, IEditableControlFactory } from '../../types'; +import { RangeSliderEmbeddableInput, RANGE_SLIDER_CONTROL } from './types'; +import { EmbeddableFactoryDefinition, IContainer } from '../../../../embeddable/public'; +import { + createRangeSliderExtract, + createRangeSliderInject, +} from '../../../common/control_types/range_slider/range_slider_persistable_state'; +import { RangeSliderStrings } from './range_slider_strings'; + +export class RangeSliderEmbeddableFactory + implements EmbeddableFactoryDefinition, IEditableControlFactory +{ + public type = RANGE_SLIDER_CONTROL; + public canCreateNew = () => false; + + constructor() {} + + public async create(initialInput: RangeSliderEmbeddableInput, parent?: IContainer) { + const { RangeSliderEmbeddable } = await import('./range_slider_embeddable'); + return Promise.resolve(new RangeSliderEmbeddable(initialInput, {}, parent)); + } + + public presaveTransformFunction = ( + newInput: Partial, + embeddable?: ControlEmbeddable + ) => { + if ( + embeddable && + (!deepEqual(newInput.fieldName, embeddable.getInput().fieldName) || + !deepEqual(newInput.dataViewId, embeddable.getInput().dataViewId)) + ) { + // if the field name or data view id has changed in this editing session, selected values are invalid, so reset them. + newInput.value = ['', '']; + } + return newInput; + }; + + public controlEditorComponent = RangeSliderEditor; + + public isEditable = () => Promise.resolve(false); + + public getDisplayName = () => RangeSliderStrings.getDisplayName(); + public getIconType = () => 'controlsHorizontal'; + public getDescription = () => RangeSliderStrings.getDescription(); + + public inject = createRangeSliderInject(); + public extract = createRangeSliderExtract(); +} diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_popover.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider_popover.tsx new file mode 100644 index 0000000000000..a4ed84ec01a2e --- /dev/null +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_popover.tsx @@ -0,0 +1,242 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FC, useState, useRef } from 'react'; +import { + EuiFieldNumber, + EuiPopoverTitle, + EuiText, + EuiInputPopover, + EuiButtonIcon, + EuiToolTip, + EuiLoadingSpinner, + EuiFlexGroup, + EuiFlexItem, + EuiDualRange, +} from '@elastic/eui'; + +import { RangeSliderStrings } from './range_slider_strings'; +import { RangeValue } from './types'; + +export interface Props { + id: string; + isLoading?: boolean; + min: string; + max: string; + title?: string; + value: RangeValue; + onChange: (value: RangeValue) => void; + fieldFormatter: (value: string) => string; +} + +export const RangeSliderPopover: FC = ({ + id, + isLoading, + min, + max, + title, + value, + onChange, + fieldFormatter, +}) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const rangeRef = useRef(null); + let errorMessage = ''; + let helpText = ''; + + const hasAvailableRange = min !== '' && max !== ''; + const hasLowerBoundSelection = value[0] !== ''; + const hasUpperBoundSelection = value[1] !== ''; + + const lowerBoundValue = parseFloat(value[0]); + const upperBoundValue = parseFloat(value[1]); + const minValue = parseFloat(min); + const maxValue = parseFloat(max); + + if (!hasAvailableRange) { + helpText = 'There is no data to display. Adjust the time range and filters.'; + } + + // EuiDualRange can only handle integers as min/max + const roundedMin = hasAvailableRange ? Math.floor(minValue) : minValue; + const roundedMax = hasAvailableRange ? Math.ceil(maxValue) : maxValue; + + const isLowerSelectionInvalid = hasLowerBoundSelection && lowerBoundValue > roundedMax; + const isUpperSelectionInvalid = hasUpperBoundSelection && upperBoundValue < roundedMin; + const isSelectionInvalid = + hasAvailableRange && (isLowerSelectionInvalid || isUpperSelectionInvalid); + + if (isSelectionInvalid) { + helpText = RangeSliderStrings.popover.getNoDataHelpText(); + } + + if (lowerBoundValue > upperBoundValue) { + errorMessage = RangeSliderStrings.errors.getUpperLessThanLowerErrorMessage(); + } + + const rangeSliderMin = Math.min( + roundedMin, + isNaN(lowerBoundValue) ? Infinity : lowerBoundValue, + isNaN(upperBoundValue) ? Infinity : upperBoundValue + ); + const rangeSliderMax = Math.max( + roundedMax, + isNaN(lowerBoundValue) ? -Infinity : lowerBoundValue, + isNaN(upperBoundValue) ? -Infinity : upperBoundValue + ); + + const displayedValue = [ + hasLowerBoundSelection ? String(lowerBoundValue) : hasAvailableRange ? String(roundedMin) : '', + hasUpperBoundSelection ? String(upperBoundValue) : hasAvailableRange ? String(roundedMax) : '', + ] as RangeValue; + + const ticks = []; + const levels = []; + + if (hasAvailableRange) { + ticks.push({ value: rangeSliderMin, label: fieldFormatter(String(rangeSliderMin)) }); + ticks.push({ value: rangeSliderMax, label: fieldFormatter(String(rangeSliderMax)) }); + levels.push({ min: roundedMin, max: roundedMax, color: 'success' }); + } + + const button = ( + + ); + + return ( + setIsPopoverOpen(false)} + anchorPosition="downCenter" + initialFocus={false} + repositionOnScroll + disableFocusTrap + onPanelResize={() => { + if (rangeRef?.current) { + rangeRef.current.onResize(); + } + }} + > + {title} + + + { + const updatedLowerBound = + typeof newLowerBound === 'number' ? String(newLowerBound) : value[0]; + const updatedUpperBound = + typeof newUpperBound === 'number' ? String(newUpperBound) : value[1]; + + onChange([updatedLowerBound, updatedUpperBound]); + }} + value={displayedValue} + ticks={hasAvailableRange ? ticks : undefined} + levels={hasAvailableRange ? levels : undefined} + showTicks={hasAvailableRange} + disabled={!hasAvailableRange} + fullWidth + ref={rangeRef} + data-test-subj="rangeSlider__slider" + /> + + {errorMessage || helpText} + + + {hasAvailableRange ? ( + + + onChange(['', ''])} + aria-label={RangeSliderStrings.popover.getClearRangeButtonTitle()} + data-test-subj="rangeSlider__clearRangeButton" + /> + + + ) : null} + + + ); +}; diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_reducers.ts b/src/plugins/controls/public/control_types/range_slider/range_slider_reducers.ts new file mode 100644 index 0000000000000..ce7e5ced101a6 --- /dev/null +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_reducers.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PayloadAction } from '@reduxjs/toolkit'; +import { WritableDraft } from 'immer/dist/types/types-external'; + +import { RangeSliderEmbeddableInput, RangeValue } from './types'; + +export const rangeSliderReducers = { + selectRange: ( + state: WritableDraft, + action: PayloadAction + ) => { + state.value = action.payload; + }, +}; diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_strings.ts b/src/plugins/controls/public/control_types/range_slider/range_slider_strings.ts new file mode 100644 index 0000000000000..a901f79ba20f5 --- /dev/null +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_strings.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +export const RangeSliderStrings = { + getDisplayName: () => + i18n.translate('controls.rangeSlider.displayName', { + defaultMessage: 'Range slider', + }), + getDescription: () => + i18n.translate('controls.rangeSlider.description', { + defaultMessage: 'Add a control for selecting a range of field values.', + }), + editor: { + getDataViewTitle: () => + i18n.translate('controls.rangeSlider.editor.dataViewTitle', { + defaultMessage: 'Data view', + }), + getNoDataViewTitle: () => + i18n.translate('controls.rangeSlider.editor.noDataViewTitle', { + defaultMessage: 'Select data view', + }), + getFieldTitle: () => + i18n.translate('controls.rangeSlider.editor.fieldTitle', { + defaultMessage: 'Field', + }), + }, + popover: { + getAllOptionsButtonTitle: () => + i18n.translate('controls.rangeSlider.popover.allOptionsTitle', { + defaultMessage: 'Show all options', + }), + getClearRangeButtonTitle: () => + i18n.translate('controls.rangeSlider.popover.clearRangeTitle', { + defaultMessage: 'Clear range', + }), + getNoDataHelpText: () => + i18n.translate('controls.rangeSlider.popover.noDataHelpText', { + defaultMessage: 'Selected range is outside of available data. No filter was applied.', + }), + }, + errors: { + getDataViewNotFoundError: (dataViewId: string) => + i18n.translate('controls.optionsList.errors.dataViewNotFound', { + defaultMessage: 'Could not locate data view: {dataViewId}', + values: { dataViewId }, + }), + getUpperLessThanLowerErrorMessage: () => + i18n.translate('controls.rangeSlider.popover.upperLessThanLowerErrorMessage', { + defaultMessage: 'The upper bound must be greater than or equal to the lower bound.', + }), + }, +}; diff --git a/src/plugins/controls/public/control_types/range_slider/types.ts b/src/plugins/controls/public/control_types/range_slider/types.ts new file mode 100644 index 0000000000000..e9ebe61bf6267 --- /dev/null +++ b/src/plugins/controls/public/control_types/range_slider/types.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from '../../../common/control_types/options_list/types'; +export * from '../../../common/control_types/range_slider/types'; diff --git a/src/plugins/controls/public/index.ts b/src/plugins/controls/public/index.ts index 7caf9d19e49b2..db4586b315075 100644 --- a/src/plugins/controls/public/index.ts +++ b/src/plugins/controls/public/index.ts @@ -24,7 +24,7 @@ export type { ControlInput, } from '../common/types'; -export { OPTIONS_LIST_CONTROL, CONTROL_GROUP_TYPE } from '../common'; +export { CONTROL_GROUP_TYPE, OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL } from '../common'; export { ControlGroupContainer, @@ -37,6 +37,9 @@ export { OptionsListEmbeddableFactory, OptionsListEmbeddable, type OptionsListEmbeddableInput, + RangeSliderEmbeddableFactory, + RangeSliderEmbeddable, + type RangeSliderEmbeddableInput, } from './control_types'; export { LazyControlsCallout, type CalloutProps } from './controls_callout'; diff --git a/src/plugins/controls/public/plugin.ts b/src/plugins/controls/public/plugin.ts index 0c81f4c826175..96cb7eeef3a27 100644 --- a/src/plugins/controls/public/plugin.ts +++ b/src/plugins/controls/public/plugin.ts @@ -20,7 +20,16 @@ import { OptionsListEmbeddableFactory, OptionsListEmbeddableInput, } from './control_types/options_list'; -import { ControlGroupContainerFactory, CONTROL_GROUP_TYPE, OPTIONS_LIST_CONTROL } from '.'; +import { + RangeSliderEmbeddableFactory, + RangeSliderEmbeddableInput, +} from './control_types/range_slider'; +import { + ControlGroupContainerFactory, + CONTROL_GROUP_TYPE, + OPTIONS_LIST_CONTROL, + RANGE_SLIDER_CONTROL, +} from '.'; import { controlsService } from './services/kibana/controls'; import { EmbeddableFactory } from '../../embeddable/public'; @@ -56,6 +65,7 @@ export class ControlsPlugin _setupPlugins: ControlsPluginSetupDeps ): ControlsPluginSetup { const { registerControlType } = controlsService; + const { embeddable } = _setupPlugins; // register control group embeddable factory _coreSetup.getStartServices().then(([, deps]) => { @@ -75,8 +85,19 @@ export class ControlsPlugin optionsListFactory ); registerControlType(optionsListFactory); + + // Register range slider + const rangeSliderFactoryDef = new RangeSliderEmbeddableFactory(); + const rangeSliderFactory = embeddable.registerEmbeddableFactory( + RANGE_SLIDER_CONTROL, + rangeSliderFactoryDef + )(); + this.transferEditorFunctions( + rangeSliderFactoryDef, + rangeSliderFactory + ); + registerControlType(rangeSliderFactory); }); - const { embeddable } = _setupPlugins; return { registerControlType, @@ -87,6 +108,7 @@ export class ControlsPlugin this.startControlsKibanaServices(coreStart, startPlugins); const { getControlFactory, getControlTypes } = controlsService; + return { getControlFactory, getControlTypes, diff --git a/src/plugins/controls/public/services/data.ts b/src/plugins/controls/public/services/data.ts index f25b3f6a95801..f11e451995535 100644 --- a/src/plugins/controls/public/services/data.ts +++ b/src/plugins/controls/public/services/data.ts @@ -11,4 +11,6 @@ import { DataPublicPluginStart } from '../../../data/public'; export interface ControlsDataService { autocomplete: DataPublicPluginStart['autocomplete']; query: DataPublicPluginStart['query']; + searchSource: DataPublicPluginStart['search']['searchSource']; + timefilter: DataPublicPluginStart['query']['timefilter']['timefilter']; } diff --git a/src/plugins/controls/public/services/kibana/data.ts b/src/plugins/controls/public/services/kibana/data.ts index e411766fe0f34..0830233109e2a 100644 --- a/src/plugins/controls/public/services/kibana/data.ts +++ b/src/plugins/controls/public/services/kibana/data.ts @@ -17,10 +17,12 @@ export type DataServiceFactory = KibanaPluginServiceFactory< export const dataServiceFactory: DataServiceFactory = ({ startPlugins }) => { const { - data: { query, autocomplete }, + data: { autocomplete, query, search }, } = startPlugins; return { autocomplete, query, + searchSource: search.searchSource, + timefilter: query.timefilter.timefilter, }; }; diff --git a/src/plugins/controls/public/services/storybook/data.ts b/src/plugins/controls/public/services/storybook/data.ts index c26d7c0835295..bfdcf05767b01 100644 --- a/src/plugins/controls/public/services/storybook/data.ts +++ b/src/plugins/controls/public/services/storybook/data.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { of } from 'rxjs'; import { PluginServiceFactory } from '../../../../presentation_util/public'; import { DataPublicPluginStart } from '../../../../data/public'; import { DataViewField } from '../../../../data_views/common'; @@ -23,4 +24,18 @@ export const dataServiceFactory: DataServiceFactory = () => ({ getValueSuggestions: valueSuggestionMethod, } as unknown as DataPublicPluginStart['autocomplete'], query: {} as unknown as DataPublicPluginStart['query'], + searchSource: { + create: () => ({ + setField: () => {}, + fetch$: () => + of({ + resp: { + rawResponse: { aggregations: { minAgg: { value: 0 }, maxAgg: { value: 1000 } } }, + }, + }), + }), + } as unknown as DataPublicPluginStart['search']['searchSource'], + timefilter: { + createFilter: () => {}, + } as unknown as DataPublicPluginStart['query']['timefilter']['timefilter'], }); diff --git a/src/plugins/presentation_util/public/__stories__/fixtures/flights.ts b/src/plugins/presentation_util/public/__stories__/fixtures/flights.ts index 0ec82b1e1994b..d999db22b4057 100644 --- a/src/plugins/presentation_util/public/__stories__/fixtures/flights.ts +++ b/src/plugins/presentation_util/public/__stories__/fixtures/flights.ts @@ -43,12 +43,22 @@ export const flightFieldNames: FlightField[] = [ 'timestamp', ]; +const numberFields = [ + 'AvgTicketPrice', + 'dayOfWeek', + 'DistanceKilometers', + 'DistanceMiles', + 'FlightDelayMin', + 'FlightTimeHour', + 'FlightTimeMin', +]; + export const flightFieldByName: { [key: string]: DataViewField } = {}; flightFieldNames.forEach( (flightFieldName) => (flightFieldByName[flightFieldName] = { name: flightFieldName, - type: 'string', + type: numberFields.includes(flightFieldName) ? 'number' : 'string', aggregatable: true, } as unknown as DataViewField) ); diff --git a/test/functional/apps/dashboard_elements/controls/index.ts b/test/functional/apps/dashboard_elements/controls/index.ts index a29834c848094..f5ec41d593995 100644 --- a/test/functional/apps/dashboard_elements/controls/index.ts +++ b/test/functional/apps/dashboard_elements/controls/index.ts @@ -49,6 +49,7 @@ export default function ({ loadTestFile, getService, getPageObjects }: FtrProvid loadTestFile(require.resolve('./controls_callout')); loadTestFile(require.resolve('./control_group_settings')); loadTestFile(require.resolve('./options_list')); + loadTestFile(require.resolve('./range_slider')); loadTestFile(require.resolve('./control_group_chaining')); }); } diff --git a/test/functional/apps/dashboard_elements/controls/options_list.ts b/test/functional/apps/dashboard_elements/controls/options_list.ts index 6c52581d2878f..ce04b2b01c091 100644 --- a/test/functional/apps/dashboard_elements/controls/options_list.ts +++ b/test/functional/apps/dashboard_elements/controls/options_list.ts @@ -100,8 +100,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const firstId = (await dashboardControls.getAllControlIds())[0]; await dashboardControls.editExistingControl(firstId); - await dashboardControls.optionsListEditorSetDataView('animals-*'); - await dashboardControls.optionsListEditorSetfield('animal.keyword'); + await dashboardControls.controlsEditorSetDataView('animals-*'); + await dashboardControls.controlsEditorSetfield('animal.keyword'); await dashboardControls.controlEditorSave(); // when creating a new filter, the ability to select a data view should be removed, because the dashboard now only has one data view diff --git a/test/functional/apps/dashboard_elements/controls/range_slider.ts b/test/functional/apps/dashboard_elements/controls/range_slider.ts new file mode 100644 index 0000000000000..b2132e1919bd6 --- /dev/null +++ b/test/functional/apps/dashboard_elements/controls/range_slider.ts @@ -0,0 +1,204 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const retry = getService('retry'); + const esArchiver = getService('esArchiver'); + const security = getService('security'); + const filterBar = getService('filterBar'); + const testSubjects = getService('testSubjects'); + const kibanaServer = getService('kibanaServer'); + const { dashboardControls, timePicker, common, dashboard } = getPageObjects([ + 'dashboardControls', + 'timePicker', + 'dashboard', + 'common', + 'header', + ]); + + describe('Range Slider Control', async () => { + before(async () => { + await security.testUser.setRoles([ + 'kibana_admin', + 'kibana_sample_admin', + 'test_logstash_reader', + ]); + await esArchiver.load('test/functional/fixtures/es_archiver/kibana_sample_data_flights'); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/kibana_sample_data_flights_index_pattern' + ); + await kibanaServer.uiSettings.replace({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + await common.navigateToApp('dashboard'); + await dashboardControls.enableControlsLab(); + await common.navigateToApp('dashboard'); + await dashboard.preserveCrossAppState(); + await dashboard.gotoDashboardLandingPage(); + await dashboard.clickNewDashboard(); + await timePicker.setAbsoluteRange( + 'Oct 22, 2018 @ 00:00:00.000', + 'Dec 3, 2018 @ 00:00:00.000' + ); + }); + + after(async () => { + await dashboardControls.clearAllControls(); + await kibanaServer.importExport.unload( + 'test/functional/fixtures/kbn_archiver/kibana_sample_data_flights_index_pattern' + ); + await esArchiver.unload('test/functional/fixtures/es_archiver/kibana_sample_data_flights'); + await kibanaServer.uiSettings.unset('defaultIndex'); + await security.testUser.restoreDefaults(); + }); + + describe('create and edit', async () => { + it('can create a new range slider control from a blank state', async () => { + await dashboardControls.createRangeSliderControl({ fieldName: 'bytes', width: 'small' }); + expect(await dashboardControls.getControlsCount()).to.be(1); + }); + + it('can add a second range list control with a non-default data view', async () => { + await dashboardControls.createRangeSliderControl({ + dataViewTitle: 'kibana_sample_data_flights', + fieldName: 'AvgTicketPrice', + width: 'medium', + }); + expect(await dashboardControls.getControlsCount()).to.be(2); + const secondId = (await dashboardControls.getAllControlIds())[1]; + expect( + await dashboardControls.rangeSliderGetLowerBoundAttribute(secondId, 'placeholder') + ).to.be('100'); + expect( + await dashboardControls.rangeSliderGetUpperBoundAttribute(secondId, 'placeholder') + ).to.be('1200'); + // data views should be properly propagated from the control group to the dashboard + expect(await filterBar.getIndexPatterns()).to.be('logstash-*,kibana_sample_data_flights'); + }); + + it('renames an existing control', async () => { + const secondId = (await dashboardControls.getAllControlIds())[1]; + const newTitle = 'Average ticket price'; + await dashboardControls.editExistingControl(secondId); + await dashboardControls.controlEditorSetTitle(newTitle); + await dashboardControls.controlEditorSave(); + expect(await dashboardControls.doesControlTitleExist(newTitle)).to.be(true); + }); + + it('can edit range slider control', async () => { + const firstId = (await dashboardControls.getAllControlIds())[0]; + await dashboardControls.editExistingControl(firstId); + await dashboardControls.controlsEditorSetDataView('kibana_sample_data_flights'); + await dashboardControls.controlsEditorSetfield('dayOfWeek'); + await dashboardControls.controlEditorSave(); + await dashboardControls.rangeSliderWaitForLoading(); + expect( + await dashboardControls.rangeSliderGetLowerBoundAttribute(firstId, 'placeholder') + ).to.be('0'); + expect( + await dashboardControls.rangeSliderGetUpperBoundAttribute(firstId, 'placeholder') + ).to.be('6'); + // when creating a new filter, the ability to select a data view should be removed, because the dashboard now only has one data view + await retry.try(async () => { + await testSubjects.click('addFilter'); + const indexPatternSelectExists = await testSubjects.exists('filterIndexPatternsSelect'); + await filterBar.ensureFieldEditorModalIsClosed(); + expect(indexPatternSelectExists).to.be(false); + }); + }); + + it('can enter lower bound selection from the number field', async () => { + const firstId = (await dashboardControls.getAllControlIds())[0]; + await dashboardControls.rangeSliderSetLowerBound(firstId, '1'); + const lowerBoundSelection = await dashboardControls.rangeSliderGetLowerBoundAttribute( + firstId, + 'value' + ); + expect(lowerBoundSelection).to.be('1'); + }); + + it('can enter upper bound selection into the number field', async () => { + const firstId = (await dashboardControls.getAllControlIds())[0]; + await dashboardControls.rangeSliderSetUpperBound(firstId, '2'); + const upperBoundSelection = await dashboardControls.rangeSliderGetUpperBoundAttribute( + firstId, + 'value' + ); + expect(upperBoundSelection).to.be('2'); + }); + + it('applies filter from the first control on the second control', async () => { + await dashboardControls.rangeSliderWaitForLoading(); + const secondId = (await dashboardControls.getAllControlIds())[1]; + const availableMin = await dashboardControls.rangeSliderGetLowerBoundAttribute( + secondId, + 'placeholder' + ); + expect(availableMin).to.be('100'); + const availabeMax = await dashboardControls.rangeSliderGetUpperBoundAttribute( + secondId, + 'placeholder' + ); + expect(availabeMax).to.be('1000'); + }); + + it('can clear out selections by clicking the reset button', async () => { + const firstId = (await dashboardControls.getAllControlIds())[0]; + await dashboardControls.rangeSliderClearSelection(firstId); + const lowerBoundSelection = await dashboardControls.rangeSliderGetLowerBoundAttribute( + firstId, + 'value' + ); + expect(lowerBoundSelection.length).to.be(0); + const upperBoundSelection = await dashboardControls.rangeSliderGetUpperBoundAttribute( + firstId, + 'value' + ); + expect(upperBoundSelection.length).to.be(0); + }); + + it('deletes an existing control', async () => { + const firstId = (await dashboardControls.getAllControlIds())[0]; + await dashboardControls.removeExistingControl(firstId); + expect(await dashboardControls.getControlsCount()).to.be(1); + }); + }); + + describe('validation', async () => { + it('displays error message when upper bound selection is less than lower bound selection', async () => { + const firstId = (await dashboardControls.getAllControlIds())[0]; + await dashboardControls.rangeSliderSetLowerBound(firstId, '500'); + await dashboardControls.rangeSliderSetUpperBound(firstId, '400'); + }); + + it('disables inputs when no data available', async () => { + await dashboardControls.createRangeSliderControl({ fieldName: 'bytes', width: 'small' }); + const secondId = (await dashboardControls.getAllControlIds())[1]; + expect( + await dashboardControls.rangeSliderGetLowerBoundAttribute(secondId, 'disabled') + ).to.be('true'); + expect( + await dashboardControls.rangeSliderGetUpperBoundAttribute(secondId, 'disabled') + ).to.be('true'); + await dashboardControls.rangeSliderOpenPopover(secondId); + await dashboardControls.rangeSliderPopoverAssertOpen(); + expect( + await dashboardControls.rangeSliderGetDualRangeAttribute(secondId, 'disabled') + ).to.be('true'); + expect((await testSubjects.getVisibleText('rangeSlider__helpText')).length).to.be.above(0); + }); + }); + }); +} diff --git a/test/functional/apps/dashboard_elements/index.ts b/test/functional/apps/dashboard_elements/index.ts index 059576389f32e..6bd3e4e04a9c9 100644 --- a/test/functional/apps/dashboard_elements/index.ts +++ b/test/functional/apps/dashboard_elements/index.ts @@ -23,6 +23,12 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/long_window_logstash'); }); + after(async () => { + await esArchiver.unload('test/functional/fixtures/es_archiver/empty_kibana'); + await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional'); + await esArchiver.unload('test/functional/fixtures/es_archiver/long_window_logstash'); + }); + describe('dashboard elements ciGroup10', function () { this.tags('ciGroup10'); diff --git a/test/functional/page_objects/dashboard_page_controls.ts b/test/functional/page_objects/dashboard_page_controls.ts index f0dd7ed9ddd3d..0f52bf1255693 100644 --- a/test/functional/page_objects/dashboard_page_controls.ts +++ b/test/functional/page_objects/dashboard_page_controls.ts @@ -8,7 +8,11 @@ import expect from '@kbn/expect'; import { WebElementWrapper } from 'test/functional/services/lib/web_element_wrapper'; -import { OPTIONS_LIST_CONTROL, ControlWidth } from '../../../src/plugins/controls/common'; +import { + OPTIONS_LIST_CONTROL, + ControlWidth, + RANGE_SLIDER_CONTROL, +} from '../../../src/plugins/controls/common'; import { ControlGroupChainingSystem } from '../../../src/plugins/controls/common/control_group/types'; import { FtrService } from '../ftr_provider_context'; @@ -238,6 +242,14 @@ export class DashboardPageControls extends FtrService { }); } + public async clickExistingControl(controlId: string) { + const elementToClick = await this.getControlElementById(controlId); + await this.retry.try(async () => { + await elementToClick.click(); + await this.testSubjects.existOrFail(`control-action-${controlId}-edit`); + }); + } + public async editExistingControl(controlId: string) { this.log.debug(`Opening control editor for control: ${controlId}`); await this.hoverOverExistingControl(controlId); @@ -344,6 +356,26 @@ export class DashboardPageControls extends FtrService { } } + public async controlsEditorSetDataView(dataViewTitle: string) { + this.log.debug(`Setting control data view to ${dataViewTitle}`); + await this.testSubjects.click('open-data-view-picker'); + await this.retry.try(async () => { + await this.testSubjects.existOrFail('data-view-picker-title'); + }); + await this.testSubjects.click(`data-view-picker-${dataViewTitle}`); + } + + public async controlsEditorSetfield(fieldName: string, shouldSearch: boolean = false) { + this.log.debug(`Setting control field to ${fieldName}`); + if (shouldSearch) { + await this.testSubjects.setValue('field-search-input', fieldName); + } + await this.retry.try(async () => { + await this.testSubjects.existOrFail(`field-picker-select-${fieldName}`); + }); + await this.testSubjects.click(`field-picker-select-${fieldName}`); + } + // Options List editor functions public async createOptionsListControl({ dataViewTitle, @@ -359,8 +391,8 @@ export class DashboardPageControls extends FtrService { this.log.debug(`Creating options list control ${title ?? fieldName}`); await this.openCreateControlFlyout(OPTIONS_LIST_CONTROL); - if (dataViewTitle) await this.optionsListEditorSetDataView(dataViewTitle); - if (fieldName) await this.optionsListEditorSetfield(fieldName); + if (dataViewTitle) await this.controlsEditorSetDataView(dataViewTitle); + if (fieldName) await this.controlsEditorSetfield(fieldName); if (title) await this.controlEditorSetTitle(title); if (width) await this.controlEditorSetWidth(width); @@ -378,23 +410,93 @@ export class DashboardPageControls extends FtrService { return dataViewName; } - public async optionsListEditorSetDataView(dataViewTitle: string) { - this.log.debug(`Setting options list data view to ${dataViewTitle}`); - await this.testSubjects.click('open-data-view-picker'); + // Range slider functions + public async rangeSliderGetLowerBoundAttribute(controlId: string, attribute: string) { + this.log.debug(`Getting range slider lower bound ${attribute} for ${controlId}`); + return await this.testSubjects.getAttribute( + `range-slider-control-${controlId} > rangeSlider__lowerBoundFieldNumber`, + attribute + ); + } + public async rangeSliderGetUpperBoundAttribute(controlId: string, attribute: string) { + this.log.debug(`Getting range slider upper bound ${attribute} for ${controlId}`); + return await this.testSubjects.getAttribute( + `range-slider-control-${controlId} > rangeSlider__upperBoundFieldNumber`, + attribute + ); + } + public async rangeSliderGetDualRangeAttribute(controlId: string, attribute: string) { + this.log.debug(`Getting range slider dual range ${attribute} for ${controlId}`); + return await this.testSubjects.getAttribute(`rangeSlider__slider`, attribute); + } + public async rangeSliderSetLowerBound(controlId: string, value: string) { + this.log.debug(`Setting range slider lower bound to ${value}`); + await this.testSubjects.setValue( + `range-slider-control-${controlId} > rangeSlider__lowerBoundFieldNumber`, + value + ); + } + public async rangeSliderSetUpperBound(controlId: string, value: string) { + this.log.debug(`Setting range slider lower bound to ${value}`); + await this.testSubjects.setValue( + `range-slider-control-${controlId} > rangeSlider__upperBoundFieldNumber`, + value + ); + } + + public async rangeSliderOpenPopover(controlId: string) { + this.log.debug(`Opening popover for Range Slider: ${controlId}`); + await this.testSubjects.click(`range-slider-control-${controlId}`); await this.retry.try(async () => { - await this.testSubjects.existOrFail('data-view-picker-title'); + await this.testSubjects.existOrFail(`rangeSlider-control-actions`); }); - await this.testSubjects.click(`data-view-picker-${dataViewTitle}`); } - public async optionsListEditorSetfield(fieldName: string, shouldSearch: boolean = false) { - this.log.debug(`Setting options list field to ${fieldName}`); - if (shouldSearch) { - await this.testSubjects.setValue('field-search-input', fieldName); - } + public async rangeSliderEnsurePopoverIsClosed(controlId: string) { + this.log.debug(`Opening popover for Range Slider: ${controlId}`); + await this.testSubjects.click(`range-slider-control-${controlId}`); + await this.testSubjects.waitForDeleted(`rangeSlider-control-actions`); + } + + public async rangeSliderPopoverAssertOpen() { await this.retry.try(async () => { - await this.testSubjects.existOrFail(`field-picker-select-${fieldName}`); + if (!(await this.testSubjects.exists(`rangeSlider-control-actions`))) { + throw new Error('options list popover must be open before calling selectOption'); + } }); - await this.testSubjects.click(`field-picker-select-${fieldName}`); + } + + public async rangeSliderWaitForLoading() { + await this.testSubjects.waitForDeleted('range-slider-loading-spinner'); + } + + public async rangeSliderClearSelection(controlId: string) { + this.log.debug(`Clearing range slider selection from control: ${controlId}`); + await this.rangeSliderOpenPopover(controlId); + await this.rangeSliderPopoverAssertOpen(); + await this.testSubjects.click('rangeSlider__clearRangeButton'); + } + + // Range slider editor functions + public async createRangeSliderControl({ + dataViewTitle, + fieldName, + width, + title, + }: { + title?: string; + fieldName: string; + width?: ControlWidth; + dataViewTitle?: string; + }) { + this.log.debug(`Creating range slider control ${title ?? fieldName}`); + await this.openCreateControlFlyout(RANGE_SLIDER_CONTROL); + + if (dataViewTitle) await this.controlsEditorSetDataView(dataViewTitle); + if (fieldName) await this.controlsEditorSetfield(fieldName); + if (title) await this.controlEditorSetTitle(title); + if (width) await this.controlEditorSetWidth(width); + + await this.controlEditorSave(); } } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 63b23f18a4229..a770b1c237e70 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -1231,7 +1231,6 @@ "controls.optionsList.editor.allowMultiselectTitle": "ドロップダウンでの複数選択を許可", "controls.optionsList.editor.dataViewTitle": "データビュー", "controls.optionsList.editor.fieldTitle": "フィールド", - "controls.optionsList.editor.indexPatternTitle": "インデックスパターン", "controls.optionsList.editor.noDataViewTitle": "データビューを選択", "controls.optionsList.errors.dataViewNotFound": "データビュー{dataViewId}が見つかりませんでした", "controls.optionsList.popover.allOptionsTitle": "すべてのオプションを表示", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e7434219172a8..d6c0753033212 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -1235,7 +1235,6 @@ "controls.optionsList.editor.allowMultiselectTitle": "下拉列表中允许多选", "controls.optionsList.editor.dataViewTitle": "数据视图", "controls.optionsList.editor.fieldTitle": "字段", - "controls.optionsList.editor.indexPatternTitle": "索引模式", "controls.optionsList.editor.noDataViewTitle": "选择数据视图", "controls.optionsList.errors.dataViewNotFound": "找不到数据视图:{dataViewId}", "controls.optionsList.popover.allOptionsTitle": "显示所有选项",