[controls] migrate range slider control to new controls framework (#186195)

Closes https://github.com/elastic/kibana/issues/184375

### Design reviewers
scss is just a copy from
https://github.com/elastic/kibana/blob/main/src/plugins/controls/public/range_slider/components/range_slider.scss.
We are migrating the controls in the examples folder. Once all controls
are migrated, we will replace the embeddable controls with the migrated
controls from the examples

### Presentation reviewers
Run range slider control in controls example application
<img width="600" alt="Screenshot 2024-06-25 at 1 07 51 PM"
src="f57b7cec-923b-4ec3-8ba5-e53d92bc3e49">

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2024-07-01 15:21:25 -06:00 committed by GitHub
parent 4849a8fdf6
commit b40fc62979
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1220 additions and 13 deletions

View file

@ -30,6 +30,7 @@ import {
PublishesDataLoading,
PublishesUnifiedSearch,
PublishesViewMode,
PublishingSubject,
useBatchedPublishingSubjects,
useStateFromPublishingSubject,
ViewMode as ViewModeType,
@ -38,10 +39,11 @@ import { toMountPoint } from '@kbn/react-kibana-mount';
import React, { useEffect, useMemo, useState } from 'react';
import useAsync from 'react-use/lib/useAsync';
import useMount from 'react-use/lib/useMount';
import { BehaviorSubject } from 'rxjs';
import { BehaviorSubject, combineLatest } from 'rxjs';
import { ControlGroupApi } from '../react_controls/control_group/types';
import { SEARCH_CONTROL_TYPE } from '../react_controls/data_controls/search_control/types';
import { TIMESLIDER_CONTROL_TYPE } from '../react_controls/timeslider_control/types';
import { RANGE_SLIDER_CONTROL_TYPE } from '../react_controls/data_controls/range_slider/types';
const toggleViewButtons = [
{
@ -57,6 +59,7 @@ const toggleViewButtons = [
];
const searchControlId = 'searchControl1';
const rangeSliderControlId = 'rangeSliderControl1';
const timesliderControlId = 'timesliderControl1';
const controlGroupPanels = {
[searchControlId]: {
@ -74,6 +77,20 @@ const controlGroupPanels = {
enhancements: {},
},
},
[rangeSliderControlId]: {
type: RANGE_SLIDER_CONTROL_TYPE,
order: 0,
grow: true,
width: 'medium',
explicitInput: {
id: rangeSliderControlId,
fieldName: 'bytes',
title: 'Bytes',
grow: true,
width: 'medium',
enhancements: {},
},
},
[timesliderControlId]: {
type: TIMESLIDER_CONTROL_TYPE,
order: 0,
@ -95,9 +112,9 @@ type MockedDashboardApi = PresentationContainer &
PublishesDataLoading &
PublishesViewMode &
PublishesUnifiedSearch & {
publishFilters: (newFilters: Filter[] | undefined) => void;
setViewMode: (newViewMode: ViewMode) => void;
setChild: (child: HasUniqueId) => void;
unifiedSearchFilters$: PublishingSubject<Filter[] | undefined>;
};
export const ReactControlExample = ({
@ -110,6 +127,15 @@ export const ReactControlExample = ({
const dataLoading$ = useMemo(() => {
return new BehaviorSubject<boolean | undefined>(false);
}, []);
const controlGroupFilters$ = useMemo(() => {
return new BehaviorSubject<Filter[] | undefined>(undefined);
}, []);
const filters$ = useMemo(() => {
return new BehaviorSubject<Filter[] | undefined>(undefined);
}, []);
const unifiedSearchFilters$ = useMemo(() => {
return new BehaviorSubject<Filter[] | undefined>(undefined);
}, []);
const timeRange$ = useMemo(() => {
return new BehaviorSubject<TimeRange | undefined>({
from: 'now-24h',
@ -127,19 +153,18 @@ export const ReactControlExample = ({
useMount(() => {
const viewMode = new BehaviorSubject<ViewModeType>(ViewMode.EDIT as ViewModeType);
const filters$ = new BehaviorSubject<Filter[] | undefined>([]);
const query$ = new BehaviorSubject<Query | AggregateQuery | undefined>(undefined);
const children$ = new BehaviorSubject<{ [key: string]: unknown }>({});
setDashboardApi({
dataLoading: dataLoading$,
viewMode,
unifiedSearchFilters$,
filters$,
query$,
timeRange$,
timeslice$,
children$,
publishFilters: (newFilters) => filters$.next(newFilters),
setViewMode: (newViewMode) => viewMode.next(newViewMode),
setChild: (child) => children$.next({ ...children$.getValue(), [child.uuid]: child }),
removePanel: () => {},
@ -187,13 +212,13 @@ export const ReactControlExample = ({
if (!controlGroupApi) return;
const subscription = controlGroupApi.filters$.subscribe((controlGroupFilters) => {
if (dashboardApi) dashboardApi.publishFilters(controlGroupFilters);
controlGroupFilters$.next(controlGroupFilters);
});
return () => {
subscription.unsubscribe();
};
}, [dashboardApi, controlGroupApi]);
}, [controlGroupFilters$, controlGroupApi]);
useEffect(() => {
if (!controlGroupApi) return;
@ -207,6 +232,18 @@ export const ReactControlExample = ({
};
}, [controlGroupApi, timeslice$]);
useEffect(() => {
const subscription = combineLatest([controlGroupFilters$, unifiedSearchFilters$]).subscribe(
([controlGroupFilters, unifiedSearchFilters]) => {
filters$.next([...(controlGroupFilters ?? []), ...(unifiedSearchFilters ?? [])]);
}
);
return () => {
subscription.unsubscribe();
};
}, [controlGroupFilters$, filters$, unifiedSearchFilters$]);
if (error || (!dataViews?.[0]?.id && !loading))
return (
<EuiEmptyPrompt
@ -297,7 +334,12 @@ export const ReactControlExample = ({
} as object,
references: [
{
name: `controlGroup_${searchControlId}:searchControlDataView`,
name: `controlGroup_${searchControlId}:${SEARCH_CONTROL_TYPE}DataView`,
type: 'index-pattern',
id: dataViews?.[0].id!,
},
{
name: `controlGroup_${rangeSliderControlId}:${RANGE_SLIDER_CONTROL_TYPE}DataView`,
type: 'index-pattern',
id: dataViews?.[0].id!,
},

View file

@ -17,6 +17,7 @@ import { PLUGIN_ID } from './constants';
import img from './control_group_image.png';
import { EditControlAction } from './react_controls/actions/edit_control_action';
import { registerControlFactory } from './react_controls/control_factory_registry';
import { RANGE_SLIDER_CONTROL_TYPE } from './react_controls/data_controls/range_slider/types';
import { SEARCH_CONTROL_TYPE } from './react_controls/data_controls/search_control/types';
import { TIMESLIDER_CONTROL_TYPE } from './react_controls/timeslider_control/types';
@ -49,6 +50,19 @@ export class ControlsExamplePlugin
});
});
registerControlFactory(RANGE_SLIDER_CONTROL_TYPE, async () => {
const [{ getRangesliderControlFactory }, [coreStart, depsStart]] = await Promise.all([
import('./react_controls/data_controls/range_slider/get_range_slider_control_factory'),
core.getStartServices(),
]);
return getRangesliderControlFactory({
core: coreStart,
data: depsStart.data,
dataViews: depsStart.data.dataViews,
});
});
registerControlFactory(SEARCH_CONTROL_TYPE, async () => {
const [{ getSearchControlFactory: getSearchEmbeddableFactory }, [coreStart, depsStart]] =
await Promise.all([

View file

@ -0,0 +1,9 @@
/*
* 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 const MIN_POPOVER_WIDTH = 300;

View file

@ -0,0 +1,69 @@
/*
* 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 { ParentIgnoreSettings } from '@kbn/controls-plugin/public';
import { Filter } from '@kbn/es-query';
import { PublishesUnifiedSearch, PublishingSubject } from '@kbn/presentation-publishing';
import { apiPublishesReload } from '@kbn/presentation-publishing/interfaces/fetch/publishes_reload';
import { BehaviorSubject, debounceTime, map, merge, Observable, switchMap } from 'rxjs';
import { DataControlFetchContext } from './types';
export function dataControlFetch$(
ignoreParentSettings$: PublishingSubject<ParentIgnoreSettings | undefined>,
parentApi: Partial<PublishesUnifiedSearch> & {
unifiedSearchFilters$?: PublishingSubject<Filter[] | undefined>;
}
): Observable<DataControlFetchContext> {
return ignoreParentSettings$.pipe(
switchMap((parentIgnoreSettings) => {
const observables: Array<Observable<unknown>> = [];
// Subscribe to parentApi.unifiedSearchFilters$ instead of parentApi.filters$
// to avoid passing control group filters back into control group
if (!parentIgnoreSettings?.ignoreFilters && parentApi.unifiedSearchFilters$) {
observables.push(parentApi.unifiedSearchFilters$);
}
if (!parentIgnoreSettings?.ignoreQuery && parentApi.query$) {
observables.push(parentApi.query$);
}
if (!parentIgnoreSettings?.ignoreTimerange && parentApi.timeRange$) {
observables.push(parentApi.timeRange$);
if (parentApi.timeslice$) {
observables.push(parentApi.timeslice$);
}
}
if (apiPublishesReload(parentApi)) {
observables.push(parentApi.reload$);
}
return observables.length ? merge(...observables) : new BehaviorSubject(undefined);
}),
debounceTime(0),
map(() => {
const parentIgnoreSettings = ignoreParentSettings$.value;
return {
unifiedSearchFilters:
parentIgnoreSettings?.ignoreFilters || !parentApi.unifiedSearchFilters$
? undefined
: parentApi.unifiedSearchFilters$.value,
query:
parentIgnoreSettings?.ignoreQuery || !parentApi.query$
? undefined
: parentApi.query$.value,
timeRange:
parentIgnoreSettings?.ignoreTimerange || !parentApi.timeRange$
? undefined
: parentApi.timeslice$?.value
? {
from: new Date(parentApi.timeslice$?.value[0]).toISOString(),
to: new Date(parentApi.timeslice$?.value[1]).toISOString(),
mode: 'absolute' as 'absolute',
}
: (parentApi as PublishesUnifiedSearch).timeRange$.value,
};
})
);
}

View file

@ -47,6 +47,7 @@ import {
ControlGroupSerializedState,
ControlGroupUnsavedChanges,
} from './types';
import { dataControlFetch$ } from './data_control_fetch';
export const getControlGroupEmbeddableFactory = (services: {
core: CoreStart;
@ -67,7 +68,7 @@ export const getControlGroupEmbeddableFactory = (services: {
labelPosition,
chainingSystem,
autoApplySelections,
ignoreParentSettings: initialParentSettings,
ignoreParentSettings,
} = initialState;
const autoApplySelections$ = new BehaviorSubject<boolean>(autoApplySelections);
@ -76,8 +77,8 @@ export const getControlGroupEmbeddableFactory = (services: {
const filters$ = new BehaviorSubject<Filter[] | undefined>([]);
const dataViews = new BehaviorSubject<DataView[] | undefined>(undefined);
const chainingSystem$ = new BehaviorSubject<ControlGroupChainingSystem>(chainingSystem);
const ignoreParentSettings = new BehaviorSubject<ParentIgnoreSettings | undefined>(
initialParentSettings
const ignoreParentSettings$ = new BehaviorSubject<ParentIgnoreSettings | undefined>(
ignoreParentSettings
);
const grow = new BehaviorSubject<boolean | undefined>(
defaultControlGrow === undefined ? DEFAULT_CONTROL_GROW : defaultControlGrow
@ -114,6 +115,8 @@ export const getControlGroupEmbeddableFactory = (services: {
.sort((a, b) => (a.order > b.order ? 1 : -1))
);
const api = setApi({
dataControlFetch$: dataControlFetch$(ignoreParentSettings$, parentApi ? parentApi : {}),
ignoreParentSettings$,
autoApplySelections$,
unsavedChanges,
resetUnsavedChanges: () => {
@ -134,7 +137,7 @@ export const getControlGroupEmbeddableFactory = (services: {
chainingSystem: chainingSystem$,
labelPosition: labelPosition$,
autoApplySelections: autoApplySelections$,
ignoreParentSettings,
ignoreParentSettings: ignoreParentSettings$,
},
{ core: services.core }
);
@ -155,7 +158,7 @@ export const getControlGroupEmbeddableFactory = (services: {
labelPosition: labelPosition$.getValue(),
chainingSystem: chainingSystem$.getValue(),
autoApplySelections: autoApplySelections$.getValue(),
ignoreParentSettings: ignoreParentSettings.getValue(),
ignoreParentSettings: ignoreParentSettings$.getValue(),
}
);
},

View file

@ -10,7 +10,7 @@ import { ControlGroupChainingSystem } from '@kbn/controls-plugin/common/control_
import { ParentIgnoreSettings } from '@kbn/controls-plugin/public';
import { ControlStyle, ControlWidth } from '@kbn/controls-plugin/public/types';
import { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public';
import { Filter } from '@kbn/es-query';
import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import { HasSerializedChildState, PresentationContainer } from '@kbn/presentation-containers';
import {
HasEditCapabilities,
@ -23,6 +23,7 @@ import {
PublishingSubject,
} from '@kbn/presentation-publishing';
import { PublishesDataViews } from '@kbn/presentation-publishing/interfaces/publishes_data_views';
import { Observable } from 'rxjs';
import { DefaultControlState, PublishesControlDisplaySettings } from '../types';
/** The control display settings published by the control group are the "default" */
@ -42,6 +43,12 @@ export type ControlGroupUnsavedChanges = Omit<
export type ControlPanelState = DefaultControlState & { type: string; order: number };
export interface DataControlFetchContext {
unifiedSearchFilters?: Filter[] | undefined;
query?: Query | AggregateQuery | undefined;
timeRange?: TimeRange | undefined;
}
export type ControlGroupApi = PresentationContainer &
DefaultEmbeddableApi<ControlGroupSerializedState, ControlGroupRuntimeState> &
PublishesFilters &
@ -54,6 +61,8 @@ export type ControlGroupApi = PresentationContainer &
PublishesTimeslice &
Partial<HasParentApi<PublishesUnifiedSearch>> & {
autoApplySelections$: PublishingSubject<boolean>;
dataControlFetch$: Observable<DataControlFetchContext>;
ignoreParentSettings$: PublishingSubject<ParentIgnoreSettings | undefined>;
};
export interface ControlGroupRuntimeState {

View file

@ -0,0 +1,52 @@
.rangeSliderAnchor__button {
.euiFormControlLayout {
align-items: center;
box-shadow: none;
background-color: transparent;
.euiFormControlLayout__childrenWrapper {
background-color: transparent;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-top-right-radius: $euiBorderRadius;
border-bottom-right-radius: $euiBorderRadius;
.euiFormControlLayoutDelimited__delimiter, .euiFormControlLayoutIcons--static {
height: auto !important;
}
}
}
.rangeSlider__invalidToken {
height: $euiSizeS * 2;
padding: 0 $euiSizeS;
.euiIcon {
background-color: transparent;
width: $euiSizeS * 2;
border-radius: $euiSizeXS;
padding: 0 calc($euiSizeXS / 2);
}
}
}
.rangeSliderAnchor__fieldNumber {
font-weight: $euiFontWeightMedium;
box-shadow: none;
text-align: center;
background-color: transparent;
&.rangeSliderAnchor__fieldNumber--valid:invalid:not(:focus) {
background-image: none; // override the red underline for values between steps
}
&.rangeSliderAnchor__fieldNumber--invalid {
color: $euiColorWarningText;
}
&:placeholder-shown, &::placeholder {
font-weight: $euiFontWeightRegular;
color: $euiTextSubduedColor;
text-decoration: none;
}
}

View file

@ -0,0 +1,227 @@
/*
* 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, useMemo, useEffect, useRef, useCallback } from 'react';
import { debounce } from 'lodash';
import { EuiRangeTick, EuiDualRange, EuiDualRangeProps, EuiToken, EuiToolTip } from '@elastic/eui';
import { RangeValue } from '../types';
import './range_slider.scss';
import { MIN_POPOVER_WIDTH } from '../../../constants';
import { RangeSliderStrings } from '../range_slider_strings';
interface Props {
fieldFormatter?: (value: string) => string;
isInvalid: boolean;
isLoading: boolean;
max: number | undefined;
min: number | undefined;
onChange: (value: RangeValue | undefined) => void;
step: number | undefined;
value: RangeValue | undefined;
uuid: string;
}
export const RangeSliderControl: FC<Props> = ({
fieldFormatter,
isInvalid,
isLoading,
max,
min,
onChange,
step,
value,
uuid,
}: Props) => {
const rangeSliderRef = useRef<EuiDualRangeProps | null>(null);
const [displayedValue, setDisplayedValue] = useState<RangeValue>(value ?? ['', '']);
const debouncedOnChange = useMemo(
() =>
debounce((newRange: RangeValue) => {
onChange(newRange);
}, 750),
[onChange]
);
/**
* This will recalculate the displayed min/max of the range slider to allow for selections smaller
* than the `min` and larger than the `max`
*/
const [displayedMin, displayedMax] = useMemo((): [number, number] => {
if (min === undefined || max === undefined) return [-Infinity, Infinity];
const selectedValue = value ?? ['', ''];
const [selectedMin, selectedMax] = [
selectedValue[0] === '' ? min : parseFloat(selectedValue[0]),
selectedValue[1] === '' ? max : parseFloat(selectedValue[1]),
];
if (!step) return [Math.min(selectedMin, min), Math.max(selectedMax, max ?? Infinity)];
const minTick = Math.floor(Math.min(selectedMin, min) / step) * step;
const maxTick = Math.ceil(Math.max(selectedMax, max) / step) * step;
return [Math.min(selectedMin, min, minTick), Math.max(selectedMax, max ?? Infinity, maxTick)];
}, [min, max, value, step]);
/**
* The following `useEffect` ensures that the changes to the value that come from the embeddable (for example,
* from the `reset` button on the dashboard or via chaining) are reflected in the displayed value
*/
useEffect(() => {
setDisplayedValue(value ?? ['', '']);
}, [value]);
const ticks: EuiRangeTick[] = useMemo(() => {
return [
{
value: displayedMin ?? -Infinity,
label: fieldFormatter ? fieldFormatter(String(displayedMin)) : displayedMin,
},
{
value: displayedMax ?? Infinity,
label: fieldFormatter ? fieldFormatter(String(displayedMax)) : displayedMax,
},
];
}, [displayedMin, displayedMax, fieldFormatter]);
const levels = useMemo(() => {
if (!step || min === undefined || max === undefined) {
return [
{
min: min ?? -Infinity,
max: max ?? Infinity,
color: 'success',
},
];
}
const roundedMin = Math.floor(min / step) * step;
const roundedMax = Math.ceil(max / step) * step;
return [
{
min: roundedMin,
max: roundedMax,
color: 'success',
},
];
}, [step, min, max]);
const disablePopover = useMemo(
() =>
isLoading ||
displayedMin === -Infinity ||
displayedMax === Infinity ||
displayedMin === displayedMax,
[isLoading, displayedMin, displayedMax]
);
const getCommonInputProps = useCallback(
({
inputValue,
testSubj,
placeholder,
}: {
inputValue: string;
testSubj: string;
placeholder: string;
}) => {
return {
isInvalid: undefined, // disabling this prop to handle our own validation styling
placeholder,
readOnly: false, // overwrites `canOpenPopover` to ensure that the inputs are always clickable
className: `rangeSliderAnchor__fieldNumber ${
isInvalid
? 'rangeSliderAnchor__fieldNumber--invalid'
: 'rangeSliderAnchor__fieldNumber--valid'
}`,
'data-test-subj': `rangeSlider__${testSubj}`,
value: inputValue === placeholder ? '' : inputValue,
title: !isInvalid && step ? '' : undefined, // overwrites native number input validation error when the value falls between two steps
};
},
[isInvalid, step]
);
const minInputProps = useMemo(() => {
return getCommonInputProps({
inputValue: displayedValue[0],
testSubj: 'lowerBoundFieldNumber',
placeholder: String(min ?? -Infinity),
});
}, [getCommonInputProps, min, displayedValue]);
const maxInputProps = useMemo(() => {
return getCommonInputProps({
inputValue: displayedValue[1],
testSubj: 'upperBoundFieldNumber',
placeholder: String(max ?? Infinity),
});
}, [getCommonInputProps, max, displayedValue]);
return (
<span className="rangeSliderAnchor__button" data-test-subj={`range-slider-control-${uuid}`}>
<EuiDualRange
ref={rangeSliderRef}
id={uuid}
fullWidth
showTicks
step={step}
ticks={ticks}
levels={levels}
min={displayedMin}
max={displayedMax}
isLoading={isLoading}
inputPopoverProps={{
panelMinWidth: MIN_POPOVER_WIDTH,
}}
append={
isInvalid ? (
<div
className="rangeSlider__invalidToken"
data-test-subj={`range-slider-control-invalid-append-${uuid}`}
>
<EuiToolTip
position="top"
content={RangeSliderStrings.control.getInvalidSelectionWarningLabel()}
delay="long"
>
<EuiToken
tabIndex={0}
iconType="alert"
size="s"
color="euiColorVis5"
shape="square"
fill="dark"
title={RangeSliderStrings.control.getInvalidSelectionWarningLabel()}
/>
</EuiToolTip>
</div>
) : undefined
}
onMouseUp={() => {
// when the pin is dropped (on mouse up), cancel any pending debounced changes and force the change
// in value to happen instantly (which, in turn, will re-calculate the min/max for the slider due to
// the `useEffect` above.
debouncedOnChange.cancel();
onChange(displayedValue);
}}
readOnly={disablePopover}
showInput={'inputWithPopover'}
data-test-subj="rangeSlider__slider"
minInputProps={minInputProps}
maxInputProps={maxInputProps}
value={[displayedValue[0] || displayedMin, displayedValue[1] || displayedMax]}
onChange={([minSelection, maxSelection]: [number | string, number | string]) => {
setDisplayedValue([String(minSelection), String(maxSelection)]);
debouncedOnChange([String(minSelection), String(maxSelection)]);
}}
/>
</span>
);
};

View file

@ -0,0 +1,210 @@
/*
* 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 from 'react';
import { estypes } from '@elastic/elasticsearch';
import { TimeRange } from '@kbn/es-query';
import { BehaviorSubject, first, of, skip } from 'rxjs';
import { render, waitFor } from '@testing-library/react';
import { coreMock } from '@kbn/core/public/mocks';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { ControlGroupApi, DataControlFetchContext } from '../../control_group/types';
import { getRangesliderControlFactory } from './get_range_slider_control_factory';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { ControlApiRegistration } from '../../types';
import { RangesliderControlApi, RangesliderControlState } from './types';
import { StateComparators } from '@kbn/presentation-publishing';
const DEFAULT_TOTAL_RESULTS = 20;
const DEFAULT_MIN = 0;
const DEFAULT_MAX = 1000;
describe('RangesliderControlApi', () => {
const uuid = 'myControl1';
const dashboardApi = {
timeRange$: new BehaviorSubject<TimeRange | undefined>(undefined),
};
const controlGroupApi = {
dataControlFetch$: new BehaviorSubject<DataControlFetchContext>({}),
ignoreParentSettings$: new BehaviorSubject(undefined),
parentApi: dashboardApi,
} as unknown as ControlGroupApi;
const dataStartServiceMock = dataPluginMock.createStartContract();
let totalResults = DEFAULT_TOTAL_RESULTS;
let min: estypes.AggregationsSingleMetricAggregateBase['value'] = DEFAULT_MIN;
let max: estypes.AggregationsSingleMetricAggregateBase['value'] = DEFAULT_MAX;
dataStartServiceMock.search.searchSource.create = jest.fn().mockImplementation(() => {
let isAggsRequest = false;
return {
setField: (key: string) => {
if (key === 'aggs') {
isAggsRequest = true;
}
},
fetch$: () => {
return isAggsRequest
? of({
rawResponse: { aggregations: { minAgg: { value: min }, maxAgg: { value: max } } },
})
: of({
rawResponse: { hits: { total: { value: totalResults } } },
});
},
};
});
const mockDataViews = dataViewPluginMocks.createStartContract();
// @ts-ignore
mockDataViews.get = async (id: string): Promise<DataView> => {
if (id !== 'myDataViewId') {
throw new Error(`Simulated error: no data view found for id ${id}`);
}
return {
id,
getFieldByName: (fieldName: string) => {
return [
{
displayName: 'My field name',
name: 'myFieldName',
type: 'string',
},
].find((field) => fieldName === field.name);
},
getFormatterForField: () => {
return {
getConverterFor: () => {
return (value: string) => `${value} myUnits`;
},
};
},
} as unknown as DataView;
};
const factory = getRangesliderControlFactory({
core: coreMock.createStart(),
data: dataStartServiceMock,
dataViews: mockDataViews,
});
beforeEach(() => {
totalResults = DEFAULT_TOTAL_RESULTS;
min = DEFAULT_MIN;
max = DEFAULT_MAX;
});
function buildApiMock(
api: ControlApiRegistration<RangesliderControlApi>,
nextComparitors: StateComparators<RangesliderControlState>
) {
return {
...api,
uuid,
parentApi: controlGroupApi,
unsavedChanges: new BehaviorSubject<Partial<RangesliderControlState> | undefined>(undefined),
resetUnsavedChanges: () => {},
type: factory.type,
};
}
describe('filters$', () => {
test('should not set filters$ when value is not provided', (done) => {
const { api } = factory.buildControl(
{
dataViewId: 'myDataView',
fieldName: 'myFieldName',
},
buildApiMock,
uuid,
controlGroupApi
);
api.filters$.pipe(skip(1), first()).subscribe((filter) => {
expect(filter).toBeUndefined();
done();
});
});
test('should set filters$ when value is provided', (done) => {
const { api } = factory.buildControl(
{
dataViewId: 'myDataViewId',
fieldName: 'myFieldName',
value: ['5', '10'],
},
buildApiMock,
uuid,
controlGroupApi
);
api.filters$.pipe(skip(1), first()).subscribe((filter) => {
expect(filter).toEqual([
{
meta: {
field: 'myFieldName',
index: 'myDataViewId',
key: 'myFieldName',
params: {
gte: 5,
lte: 10,
},
type: 'range',
},
query: {
range: {
myFieldName: {
gte: 5,
lte: 10,
},
},
},
},
]);
done();
});
});
});
describe('selected range has no results', () => {
test('should display invalid state', async () => {
totalResults = 0; // simulate no results by returning hits total of zero
min = null; // simulate no results by returning min aggregation value of null
max = null; // simulate no results by returning max aggregation value of null
const { Component } = factory.buildControl(
{
dataViewId: 'myDataViewId',
fieldName: 'myFieldName',
value: ['5', '10'],
},
buildApiMock,
uuid,
controlGroupApi
);
const { findByTestId } = render(<Component />);
await waitFor(async () => {
await findByTestId('range-slider-control-invalid-append-myControl1');
});
});
});
describe('min max', () => {
test('bounds inputs should display min and max placeholders when there is no selected range', async () => {
const { Component } = factory.buildControl(
{
dataViewId: 'myDataViewId',
fieldName: 'myFieldName',
},
buildApiMock,
uuid,
controlGroupApi
);
const { findByTestId } = render(<Component />);
await waitFor(async () => {
const minInput = await findByTestId('rangeSlider__lowerBoundFieldNumber');
expect(minInput).toHaveAttribute('placeholder', String(DEFAULT_MIN));
const maxInput = await findByTestId('rangeSlider__upperBoundFieldNumber');
expect(maxInput).toHaveAttribute('placeholder', String(DEFAULT_MAX));
});
});
});
});

View file

@ -0,0 +1,263 @@
/*
* 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, { useEffect, useMemo } from 'react';
import deepEqual from 'react-fast-compare';
import { BehaviorSubject, combineLatest, distinctUntilChanged, map, skip } from 'rxjs';
import { EuiFieldNumber, EuiFormRow } from '@elastic/eui';
import {
useBatchedPublishingSubjects,
useStateFromPublishingSubject,
} from '@kbn/presentation-publishing';
import { buildRangeFilter, Filter, RangeFilterParams } from '@kbn/es-query';
import { initializeDataControl } from '../initialize_data_control';
import { DataControlFactory } from '../types';
import {
RangesliderControlApi,
RangesliderControlState,
RangeValue,
RANGE_SLIDER_CONTROL_TYPE,
Services,
} from './types';
import { RangeSliderStrings } from './range_slider_strings';
import { RangeSliderControl } from './components/range_slider_control';
import { minMax$ } from './min_max';
import { hasNoResults$ } from './has_no_results';
export const getRangesliderControlFactory = (
services: Services
): DataControlFactory<RangesliderControlState, RangesliderControlApi> => {
return {
type: RANGE_SLIDER_CONTROL_TYPE,
getIconType: () => 'controlsHorizontal',
getDisplayName: RangeSliderStrings.control.getDisplayName,
isFieldCompatible: (field) => {
return field.aggregatable && field.type === 'number';
},
CustomOptionsComponent: ({ stateManager, setControlEditorValid }) => {
const step = useStateFromPublishingSubject(stateManager.step);
return (
<>
<EuiFormRow fullWidth label={RangeSliderStrings.editor.getStepTitle()}>
<EuiFieldNumber
value={step}
onChange={(event) => {
const newStep = event.target.valueAsNumber;
stateManager.step.next(newStep);
setControlEditorValid(newStep > 0);
}}
min={0}
isInvalid={step === undefined || step <= 0}
data-test-subj="rangeSliderControl__stepAdditionalSetting"
/>
</EuiFormRow>
</>
);
},
buildControl: (initialState, buildApi, uuid, controlGroupApi) => {
const loadingMinMax$ = new BehaviorSubject<boolean>(false);
const loadingHasNoResults$ = new BehaviorSubject<boolean>(false);
const dataLoading$ = new BehaviorSubject<boolean | undefined>(undefined);
const step$ = new BehaviorSubject<number | undefined>(initialState.step);
const value$ = new BehaviorSubject<RangeValue | undefined>(initialState.value);
function setValue(nextValue: RangeValue | undefined) {
value$.next(nextValue);
}
const dataControl = initializeDataControl<Pick<RangesliderControlState, 'step' | 'value'>>(
uuid,
RANGE_SLIDER_CONTROL_TYPE,
initialState,
{
step: step$,
value: value$,
},
controlGroupApi,
services
);
const api = buildApi(
{
...dataControl.api,
dataLoading: dataLoading$,
getTypeDisplayName: RangeSliderStrings.control.getDisplayName,
serializeState: () => {
const { rawState: dataControlState, references } = dataControl.serialize();
return {
rawState: {
...dataControlState,
step: step$.getValue(),
value: value$.getValue(),
},
references, // does not have any references other than those provided by the data control serializer
};
},
clearSelections: () => {
value$.next(undefined);
},
},
{
...dataControl.comparators,
step: [step$, (nextStep: number | undefined) => step$.next(nextStep)],
value: [value$, setValue],
}
);
const dataLoadingSubscription = combineLatest([loadingMinMax$, loadingHasNoResults$])
.pipe(
map((values) => {
return values.some((value) => {
return value;
});
})
)
.subscribe((isLoading) => {
dataLoading$.next(isLoading);
});
// Clear state when the field changes
const fieldChangedSubscription = combineLatest([
dataControl.stateManager.fieldName,
dataControl.stateManager.dataViewId,
])
.pipe(
distinctUntilChanged(deepEqual),
skip(1) // skip first filter output because it will have been applied in initialize
)
.subscribe(() => {
step$.next(1);
value$.next(undefined);
});
const max$ = new BehaviorSubject<number | undefined>(undefined);
const min$ = new BehaviorSubject<number | undefined>(undefined);
const minMaxSubscription = minMax$({
data: services.data,
dataControlFetch$: controlGroupApi.dataControlFetch$,
dataViews$: dataControl.api.dataViews,
fieldName$: dataControl.stateManager.fieldName,
setIsLoading: (isLoading: boolean) => {
// clear previous loading error on next loading start
if (isLoading && dataControl.api.blockingError.value) {
dataControl.api.setBlockingError(undefined);
}
loadingMinMax$.next(isLoading);
},
}).subscribe(
({
error,
min,
max,
}: {
error?: Error;
min: number | undefined;
max: number | undefined;
}) => {
if (error) {
dataControl.api.setBlockingError(error);
}
max$.next(max);
min$.next(min);
}
);
const outputFilterSubscription = combineLatest([
dataControl.api.dataViews,
dataControl.stateManager.fieldName,
value$,
]).subscribe(([dataViews, fieldName, value]) => {
const dataView = dataViews?.[0];
const dataViewField =
dataView && fieldName ? dataView.getFieldByName(fieldName) : undefined;
const gte = parseFloat(value?.[0] ?? '');
const lte = parseFloat(value?.[1] ?? '');
let rangeFilter: Filter | undefined;
if (value && dataView && dataViewField && !isNaN(gte) && !isNaN(lte)) {
const params = {
gte,
lte,
} as RangeFilterParams;
rangeFilter = buildRangeFilter(dataViewField, params, dataView);
rangeFilter.meta.key = fieldName;
rangeFilter.meta.type = 'range';
rangeFilter.meta.params = params;
}
api.setOutputFilter(rangeFilter);
});
const selectionHasNoResults$ = new BehaviorSubject(false);
const hasNotResultsSubscription = hasNoResults$({
data: services.data,
dataViews$: dataControl.api.dataViews,
filters$: dataControl.api.filters$,
ignoreParentSettings$: controlGroupApi.ignoreParentSettings$,
dataControlFetch$: controlGroupApi.dataControlFetch$,
setIsLoading: (isLoading: boolean) => {
loadingHasNoResults$.next(isLoading);
},
}).subscribe((hasNoResults) => {
selectionHasNoResults$.next(hasNoResults);
});
return {
api,
Component: () => {
const [dataLoading, dataViews, fieldName, max, min, selectionHasNotResults, step, value] =
useBatchedPublishingSubjects(
dataLoading$,
dataControl.api.dataViews,
dataControl.stateManager.fieldName,
max$,
min$,
selectionHasNoResults$,
step$,
value$
);
useEffect(() => {
return () => {
dataLoadingSubscription.unsubscribe();
fieldChangedSubscription.unsubscribe();
hasNotResultsSubscription.unsubscribe();
minMaxSubscription.unsubscribe();
outputFilterSubscription.unsubscribe();
};
}, []);
const fieldFormatter = useMemo(() => {
const dataView = dataViews?.[0];
if (!dataView) {
return undefined;
}
const fieldSpec = dataView.getFieldByName(fieldName);
return fieldSpec
? dataView.getFormatterForField(fieldSpec).getConverterFor('text')
: undefined;
}, [dataViews, fieldName]);
return (
<RangeSliderControl
fieldFormatter={fieldFormatter}
isInvalid={selectionHasNotResults}
isLoading={typeof dataLoading === 'boolean' ? dataLoading : false}
max={max}
min={min}
onChange={setValue}
step={step}
value={value}
uuid={uuid}
/>
);
},
};
},
};
};

View file

@ -0,0 +1,117 @@
/*
* 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 { estypes } from '@elastic/elasticsearch';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { DataView } from '@kbn/data-views-plugin/public';
import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import { PublishesDataViews } from '@kbn/presentation-publishing';
import { combineLatest, lastValueFrom, switchMap, tap } from 'rxjs';
import { ControlGroupApi } from '../../control_group/types';
import { DataControlApi } from '../types';
export function hasNoResults$({
data,
dataControlFetch$,
dataViews$,
filters$,
ignoreParentSettings$,
setIsLoading,
}: {
data: DataPublicPluginStart;
dataControlFetch$: ControlGroupApi['dataControlFetch$'];
dataViews$?: PublishesDataViews['dataViews'];
filters$: DataControlApi['filters$'];
ignoreParentSettings$: ControlGroupApi['ignoreParentSettings$'];
setIsLoading: (isLoading: boolean) => void;
}) {
let prevRequestAbortController: AbortController | undefined;
return combineLatest([filters$, ignoreParentSettings$, dataControlFetch$]).pipe(
tap(() => {
if (prevRequestAbortController) {
prevRequestAbortController.abort();
prevRequestAbortController = undefined;
}
}),
switchMap(async ([filters, ignoreParentSettings, dataControlFetchContext]) => {
const dataView = dataViews$?.value?.[0];
const rangeFilter = filters?.[0];
if (!dataView || !rangeFilter || ignoreParentSettings?.ignoreValidations) {
return false;
}
try {
setIsLoading(true);
const abortController = new AbortController();
prevRequestAbortController = abortController;
return await hasNoResults({
abortSignal: abortController.signal,
data,
dataView,
rangeFilter,
...dataControlFetchContext,
});
} catch (error) {
// Ignore error, validation is not required for control to function properly
return false;
}
}),
tap(() => {
setIsLoading(false);
})
);
}
async function hasNoResults({
abortSignal,
data,
dataView,
unifiedSearchFilters,
query,
rangeFilter,
timeRange,
}: {
abortSignal: AbortSignal;
data: DataPublicPluginStart;
dataView: DataView;
unifiedSearchFilters?: Filter[];
query?: Query | AggregateQuery;
rangeFilter: Filter;
timeRange?: TimeRange;
}): Promise<boolean> {
const searchSource = await data.search.searchSource.create();
searchSource.setField('size', 0);
searchSource.setField('index', dataView);
// Tracking total hits accurately has a performance cost
// Setting 'trackTotalHits' to 1 since we just want to know
// "has no results" or "has results" vs the actual count
searchSource.setField('trackTotalHits', 1);
const allFilters = unifiedSearchFilters ? unifiedSearchFilters : [];
allFilters.push(rangeFilter);
if (timeRange) {
const timeFilter = data.query.timefilter.timefilter.createFilter(dataView, timeRange);
if (timeFilter) allFilters.push(timeFilter);
}
if (allFilters.length) {
searchSource.setField('filter', allFilters);
}
if (query) {
searchSource.setField('query', query);
}
const resp = await lastValueFrom(
searchSource.fetch$({
abortSignal,
legacyHitsTotal: false,
})
);
const count = (resp?.rawResponse?.hits?.total as estypes.SearchTotalHits)?.value ?? 0;
return count === 0;
}

View file

@ -0,0 +1,129 @@
/*
* 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 { estypes } from '@elastic/elasticsearch';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import { PublishesDataViews, PublishingSubject } from '@kbn/presentation-publishing';
import { combineLatest, lastValueFrom, switchMap, tap } from 'rxjs';
import { ControlGroupApi } from '../../control_group/types';
export function minMax$({
data,
dataControlFetch$,
dataViews$,
fieldName$,
setIsLoading,
}: {
data: DataPublicPluginStart;
dataControlFetch$: ControlGroupApi['dataControlFetch$'];
dataViews$: PublishesDataViews['dataViews'];
fieldName$: PublishingSubject<string>;
setIsLoading: (isLoading: boolean) => void;
}) {
let prevRequestAbortController: AbortController | undefined;
return combineLatest([dataViews$, fieldName$, dataControlFetch$]).pipe(
tap(() => {
if (prevRequestAbortController) {
prevRequestAbortController.abort();
prevRequestAbortController = undefined;
}
}),
switchMap(async ([dataViews, fieldName, dataControlFetchContext]) => {
const dataView = dataViews?.[0];
const dataViewField = dataView && fieldName ? dataView.getFieldByName(fieldName) : undefined;
if (!dataView || !dataViewField) {
return { max: undefined, min: undefined };
}
try {
setIsLoading(true);
const abortController = new AbortController();
prevRequestAbortController = abortController;
return await getMinMax({
abortSignal: abortController.signal,
data,
dataView,
field: dataViewField,
...dataControlFetchContext,
});
} catch (error) {
return { error, max: undefined, min: undefined };
}
}),
tap(() => {
setIsLoading(false);
})
);
}
export async function getMinMax({
abortSignal,
data,
dataView,
field,
unifiedSearchFilters,
query,
timeRange,
}: {
abortSignal: AbortSignal;
data: DataPublicPluginStart;
dataView: DataView;
field: DataViewField;
unifiedSearchFilters?: Filter[];
query?: Query | AggregateQuery;
timeRange?: TimeRange;
}): Promise<{ min: number | undefined; max: number | undefined }> {
const searchSource = await data.search.searchSource.create();
searchSource.setField('size', 0);
searchSource.setField('index', dataView);
const allFilters = unifiedSearchFilters ? unifiedSearchFilters : [];
if (timeRange) {
const timeFilter = data.query.timefilter.timefilter.createFilter(dataView, timeRange);
if (timeFilter) allFilters.push(timeFilter);
}
if (allFilters.length) {
searchSource.setField('filter', allFilters);
}
if (query) {
searchSource.setField('query', query);
}
const aggBody: any = {};
if (field.scripted) {
aggBody.script = {
source: field.script,
lang: field.lang,
};
} else {
aggBody.field = field.name;
}
const aggs = {
maxAgg: {
max: aggBody,
},
minAgg: {
min: aggBody,
},
};
searchSource.setField('aggs', aggs);
const resp = await lastValueFrom(searchSource.fetch$({ abortSignal }));
return {
min:
(resp.rawResponse?.aggregations?.minAgg as estypes.AggregationsSingleMetricAggregateBase)
?.value ?? undefined,
max:
(resp.rawResponse?.aggregations?.maxAgg as estypes.AggregationsSingleMetricAggregateBase)
?.value ?? undefined,
};
}

View file

@ -0,0 +1,34 @@
/*
* 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 = {
control: {
getDisplayName: () =>
i18n.translate('controlsExamples.rangeSliderControl.displayName', {
defaultMessage: 'Range slider',
}),
getInvalidSelectionWarningLabel: () =>
i18n.translate('controlsExamples.rangeSlider.control.invalidSelectionWarningLabel', {
defaultMessage: 'Selected range returns no results.',
}),
},
editor: {
getStepTitle: () =>
i18n.translate('controlsExamples.rangeSlider.editor.stepSizeTitle', {
defaultMessage: 'Step size',
}),
},
popover: {
getNoAvailableDataHelpText: () =>
i18n.translate('controlsExamples.rangeSlider.popover.noAvailableDataHelpText', {
defaultMessage: 'There is no data to display. Adjust the time range and filters.',
}),
},
};

View file

@ -0,0 +1,29 @@
/*
* 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 { CoreStart } from '@kbn/core/public';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { DataControlApi, DefaultDataControlState } from '../types';
export const RANGE_SLIDER_CONTROL_TYPE = 'rangeSlider';
export type RangeValue = [string, string];
export interface RangesliderControlState extends DefaultDataControlState {
value?: RangeValue;
step?: number;
}
export type RangesliderControlApi = DataControlApi;
export interface Services {
core: CoreStart;
data: DataPublicPluginStart;
dataViews: DataViewsPublicPluginStart;
}