mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
4849a8fdf6
commit
b40fc62979
14 changed files with 1220 additions and 13 deletions
|
@ -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!,
|
||||
},
|
||||
|
|
|
@ -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([
|
||||
|
|
|
@ -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;
|
|
@ -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,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
|
@ -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(),
|
||||
}
|
||||
);
|
||||
},
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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.',
|
||||
}),
|
||||
},
|
||||
};
|
|
@ -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;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue