From ba6be79adb3dcf4a644929b35f665f9d64b6f7cd Mon Sep 17 00:00:00 2001 From: Catherine Liu Date: Mon, 28 Mar 2022 14:06:47 -0700 Subject: [PATCH] [Controls] Range slider (#125584) * Adds range slider control Fix ts error Fix ref type error Extracted i18n strings Fixed number rounding Fixed missing i18n string Add loading state to range slider control output Remove unnecessary change Fix i18n errors Apply formatter to range slider tick labels * Apply comment updates from code review Co-authored-by: Devon Thomson * Remove extra fetches * set min width for panel * Fix functional tests * Fixed controls page object Co-authored-by: Devon Thomson Co-authored-by: andreadelrio Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../range_slider_persistable_state.ts | 47 +++ .../control_types/range_slider/types.ts | 19 + src/plugins/controls/common/index.ts | 7 +- .../public/__stories__/controls.stories.tsx | 61 ++++ .../storybook_control_factories.ts | 8 + .../controls/public/control_types/index.ts | 1 + .../options_list/options_list_strings.ts | 4 - .../control_types/range_slider/index.ts | 13 + .../range_slider/range_slider.component.tsx | 72 ++++ .../range_slider/range_slider.scss | 57 +++ .../range_slider/range_slider_editor.tsx | 104 ++++++ .../range_slider/range_slider_embeddable.tsx | 330 ++++++++++++++++++ .../range_slider_embeddable_factory.tsx | 59 ++++ .../range_slider/range_slider_popover.tsx | 242 +++++++++++++ .../range_slider/range_slider_reducers.ts | 21 ++ .../range_slider/range_slider_strings.ts | 59 ++++ .../control_types/range_slider/types.ts | 10 + src/plugins/controls/public/index.ts | 5 +- src/plugins/controls/public/plugin.ts | 26 +- src/plugins/controls/public/services/data.ts | 2 + .../controls/public/services/kibana/data.ts | 4 +- .../public/services/storybook/data.ts | 15 + .../public/__stories__/fixtures/flights.ts | 12 +- .../apps/dashboard_elements/controls/index.ts | 1 + .../controls/options_list.ts | 4 +- .../controls/range_slider.ts | 204 +++++++++++ .../apps/dashboard_elements/index.ts | 6 + .../page_objects/dashboard_page_controls.ts | 132 ++++++- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 30 files changed, 1497 insertions(+), 30 deletions(-) create mode 100644 src/plugins/controls/common/control_types/range_slider/range_slider_persistable_state.ts create mode 100644 src/plugins/controls/common/control_types/range_slider/types.ts create mode 100644 src/plugins/controls/public/control_types/range_slider/index.ts create mode 100644 src/plugins/controls/public/control_types/range_slider/range_slider.component.tsx create mode 100644 src/plugins/controls/public/control_types/range_slider/range_slider.scss create mode 100644 src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx create mode 100644 src/plugins/controls/public/control_types/range_slider/range_slider_embeddable.tsx create mode 100644 src/plugins/controls/public/control_types/range_slider/range_slider_embeddable_factory.tsx create mode 100644 src/plugins/controls/public/control_types/range_slider/range_slider_popover.tsx create mode 100644 src/plugins/controls/public/control_types/range_slider/range_slider_reducers.ts create mode 100644 src/plugins/controls/public/control_types/range_slider/range_slider_strings.ts create mode 100644 src/plugins/controls/public/control_types/range_slider/types.ts create mode 100644 test/functional/apps/dashboard_elements/controls/range_slider.ts 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": "显示所有选项",